]> git.ipfire.org Git - ipfire.org.git/blame - src/backend/blog.py
blog/wiki: Only create Bugzilla links for numbers of 5 or more digits
[ipfire.org.git] / src / backend / blog.py
CommitLineData
0a6875dc
MT
1#!/usr/bin/python
2
541c952b 3import datetime
c70a7c29 4import feedparser
e9c6d581 5import markdown
023cdd01
MT
6import markdown.extensions
7import markdown.preprocessors
c70a7c29 8import re
7e64f6a3 9import textile
d1a77356 10import tornado.gen
c70a7c29 11import unicodedata
7e64f6a3 12
0a6875dc 13from . import misc
9523790a 14from . import util
a3a850a4 15from .decorators import *
0a6875dc
MT
16
17class Blog(misc.Object):
18 def _get_post(self, query, *args):
19 res = self.db.get(query, *args)
20
21 if res:
22 return Post(self.backend, res.id, data=res)
23
24 def _get_posts(self, query, *args):
25 res = self.db.query(query, *args)
26
27 for row in res:
28 yield Post(self.backend, row.id, data=row)
29
487417ad
MT
30 def get_by_id(self, id):
31 return self._get_post("SELECT * FROM blog \
32 WHERE id = %s", id)
33
df157ede
MT
34 def get_by_slug(self, slug, published=True):
35 if published:
36 return self._get_post("SELECT * FROM blog \
37 WHERE slug = %s AND published_at <= NOW()", slug)
38
0a6875dc 39 return self._get_post("SELECT * FROM blog \
df157ede
MT
40 WHERE slug = %s", slug)
41
0a6875dc
MT
42 def get_newest(self, limit=None):
43 return self._get_posts("SELECT * FROM blog \
44 WHERE published_at IS NOT NULL \
45 AND published_at <= NOW() \
46 ORDER BY published_at DESC LIMIT %s", limit)
47
48 def get_by_tag(self, tag, limit=None):
49 return self._get_posts("SELECT * FROM blog \
50 WHERE published_at IS NOT NULL \
51 AND published_at <= NOW() \
52 AND %s = ANY(tags) \
4bde7f18 53 ORDER BY published_at DESC LIMIT %s", tag, limit)
0a6875dc 54
cdf85ee7 55 def get_by_author(self, author, limit=None):
0a6875dc 56 return self._get_posts("SELECT * FROM blog \
cdf85ee7 57 WHERE (author = %s OR author_uid = %s) \
0a6875dc
MT
58 AND published_at IS NOT NULL \
59 AND published_at <= NOW() \
cdf85ee7
MT
60 ORDER BY published_at DESC LIMIT %s",
61 author.name, author.uid, limit)
0a6875dc 62
7e64f6a3
MT
63 def get_by_year(self, year):
64 return self._get_posts("SELECT * FROM blog \
65 WHERE EXTRACT(year FROM published_at) = %s \
66 AND published_at IS NOT NULL \
67 AND published_at <= NOW() \
68 ORDER BY published_at DESC", year)
69
0b342a05
MT
70 def get_drafts(self, author=None, limit=None):
71 if author:
72 return self._get_posts("SELECT * FROM blog \
73 WHERE author_uid = %s \
74 AND (published_at IS NULL OR published_at > NOW()) \
75 ORDER BY COALESCE(updated_at, created_at) DESC LIMIT %s",
76 author.uid, limit)
77
78 return self._get_posts("SELECT * FROM blog \
79 WHERE (published_at IS NULL OR published_at > NOW()) \
80 ORDER BY COALESCE(updated_at, created_at) DESC LIMIT %s", limit)
81
0a6875dc 82 def search(self, query, limit=None):
9523790a 83 query = util.parse_search_query(query)
a47ddc3d 84
0a6875dc
MT
85 return self._get_posts("SELECT blog.* FROM blog \
86 LEFT JOIN blog_search_index search_index ON blog.id = search_index.post_id \
87 WHERE search_index.document @@ to_tsquery('english', %s) \
88 ORDER BY ts_rank(search_index.document, to_tsquery('english', %s)) DESC \
89 LIMIT %s", query, query, limit)
90
611adbfb
MT
91 def has_had_recent_activity(self, t=None):
92 if t is None:
93 t = datetime.timedelta(hours=24)
94
95 res = self.db.get("SELECT COUNT(*) AS count FROM blog \
96 WHERE published_at IS NOT NULL AND published_at >= NOW() - %s", t)
97
98 if res and res.count > 0:
99 return True
100
101 return False
102
694c4f08 103 def create_post(self, title, text, author, tags=[], lang="markdown"):
541c952b
MT
104 """
105 Creates a new post and returns the resulting Post object
106 """
694c4f08
MT
107 # Pre-render HTML
108 html = self._render_text(text, lang=lang)
109
110 return self._get_post("INSERT INTO blog(title, slug, text, html, lang, author_uid, tags) \
111 VALUES(%s, %s, %s, %s, %s, %s, %s) RETURNING *", title, self._make_slug(title), text,
112 html, lang, author.uid, list(tags))
541c952b 113
c70a7c29
MT
114 def _make_slug(self, s):
115 # Remove any non-ASCII characters
116 try:
117 s = unicodedata.normalize("NFKD", s)
118 except TypeError:
119 pass
120
121 # Remove excessive whitespace
122 s = re.sub(r"[^\w]+", " ", s)
123
124 slug = "-".join(s.split()).lower()
125
126 while True:
127 e = self.db.get("SELECT 1 FROM blog WHERE slug = %s", slug)
128 if not e:
129 break
130
131 slug += "-"
132
133 return slug
134
2de5ad8a
MT
135 def _render_text(self, text, lang="markdown"):
136 if lang == "markdown":
023cdd01 137 return markdown.markdown(text,
e9c6d581 138 extensions=[
023cdd01 139 PrettyLinksExtension(),
e9c6d581
MT
140 "codehilite",
141 "fenced_code",
e0ef6d39 142 "footnotes",
e9c6d581
MT
143 "nl2br",
144 "sane_lists",
e0ef6d39 145 "tables",
e9c6d581 146 "toc",
e0ef6d39 147 ])
2de5ad8a
MT
148
149 elif lang == "textile":
150 return textile.textile(text)
151
152 return text
153
0a6875dc
MT
154 def refresh(self):
155 """
156 Needs to be called after a post has been changed
157 and updates the search index.
158 """
159 self.db.execute("REFRESH MATERIALIZED VIEW blog_search_index")
160
7e64f6a3
MT
161 @property
162 def years(self):
163 res = self.db.query("SELECT DISTINCT EXTRACT(year FROM published_at)::integer AS year \
164 FROM blog WHERE published_at IS NOT NULL AND published_at <= NOW() \
165 ORDER BY year DESC")
166
167 for row in res:
168 yield row.year
169
d1a77356 170 @tornado.gen.coroutine
c70a7c29
MT
171 def update_feeds(self):
172 """
173 Updates all enabled feeds
174 """
175 for feed in self.db.query("SELECT * FROM blog_feeds WHERE enabled IS TRUE"):
176 try:
177 f = feedparser.parse(feed.url)
178 except Exception as e:
179 raise e
180
181 with self.db.transaction():
182 # Update name
183 self.db.execute("UPDATE blog_feeds SET name = %s \
184 WHERE id = %s", f.feed.title, feed.id)
185
186 # Walk through all entries
187 for entry in f.entries:
188 # Skip everything without the "blog.ipfire.org" tag
189 try:
190 tags = list((t.term for t in entry.tags))
191
192 if not "blog.ipfire.org" in tags:
193 continue
194 except AttributeError:
195 continue
196
197 # Get link to the posting site
198 link = entry.links[0].href
199
200 # Check if the entry has already been imported
201 res = self.db.get("SELECT id, (updated_at < %s) AS needs_update \
202 FROM blog WHERE feed_id = %s AND foreign_id = %s",
203 entry.updated, feed.id, entry.id)
204 if res:
205 # If the post needs to be updated, we do so
206 if res.needs_update:
207 self.db.execute("UPDATE blog SET title = %s, author = %s, \
208 published_at = %s, updated_at = %s, html = %s, link = %s, \
209 tags = %s WHERE id = %s", entry.title, entry.author,
210 entry.published, entry.updated, entry.summary, link,
211 feed.tags + tags, res.id)
212
213 # Done here
214 continue
215
216 # Insert the new post
217 self.db.execute("INSERT INTO blog(title, slug, author, \
218 published_at, html, link, tags, updated_at, feed_id, foreign_id) \
219 VALUES(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
220 entry.title, self._make_slug(entry.title), entry.author,
221 entry.published, entry.summary, link, feed.tags + tags,
222 entry.updated, feed.id, entry.id)
223
20277bf5
MT
224 # Mark feed as updated
225 self.db.execute("UPDATE blog_feeds SET last_updated_at = CURRENT_TIMESTAMP \
226 WHERE id = %s" % feed.id)
227
c70a7c29
MT
228 # Refresh the search index
229 with self.db.transaction():
230 self.refresh()
231
0a6875dc
MT
232
233class Post(misc.Object):
234 def init(self, id, data=None):
235 self.id = id
236 self.data = data
237
541c952b
MT
238 # Title
239
93725180
MT
240 @property
241 def title(self):
0a6875dc
MT
242 return self.data.title
243
244 @property
245 def slug(self):
246 return self.data.slug
247
a3a850a4 248 @lazy_property
0a6875dc
MT
249 def author(self):
250 if self.data.author_uid:
251 return self.backend.accounts.get_by_uid(self.data.author_uid)
252
cdf85ee7
MT
253 return self.data.author
254
0a6875dc
MT
255 @property
256 def created_at(self):
257 return self.data.created_at
258
2de5ad8a
MT
259 @property
260 def lang(self):
261 return self.data.lang
262
541c952b
MT
263 # Published?
264
0a6875dc
MT
265 @property
266 def published_at(self):
267 return self.data.published_at
268
541c952b
MT
269 def is_published(self):
270 """
271 Returns True if the post is already published
272 """
273 return self.published_at and self.published_at <= datetime.datetime.now()
274
9ea64cef 275 def publish(self, when=None):
541c952b
MT
276 if self.is_published():
277 return
278
9ea64cef
MT
279 self.db.execute("UPDATE blog SET published_at = COALESCE(%s, CURRENT_TIMESTAMP) \
280 WHERE id = %s", when, self.id)
541c952b
MT
281
282 # Update search indices
283 self.backend.blog.refresh()
284
285 # Updated?
286
7e64f6a3 287 @property
541c952b
MT
288 def updated_at(self):
289 return self.data.updated_at
290
541c952b
MT
291 # Text
292
93725180
MT
293 @property
294 def text(self):
541c952b
MT
295 return self.data.text
296
541c952b 297 # HTML
7e64f6a3 298
a3a850a4 299 @lazy_property
0a6875dc
MT
300 def html(self):
301 """
302 Returns this post as rendered HTML
303 """
2de5ad8a 304 return self.data.html or self.backend.blog._render_text(self.text, lang=self.lang)
8ebc98d4 305
541c952b
MT
306 # Tags
307
93725180
MT
308 @property
309 def tags(self):
8ebc98d4 310 return self.data.tags
1e76fec4 311
93725180 312 # Link
541c952b 313
1e76fec4
MT
314 @property
315 def link(self):
316 return self.data.link
984e4e7b 317
a3a850a4 318 @lazy_property
984e4e7b
MT
319 def release(self):
320 return self.backend.releases._get_release("SELECT * FROM releases \
321 WHERE published IS NOT NULL AND published <= NOW() AND blog_id = %s", self.id)
e8a81a70
MT
322
323 def is_editable(self, editor):
324 # Authors can edit their own posts
325 return self.author == editor
93725180
MT
326
327 def update(self, title, text, tags=[]):
328 """
329 Called to update the content of this post
330 """
331 # Update slug when post isn't published yet
baa294fb
MT
332 slug = self.backend.blog._make_slug(title) \
333 if not self.is_published() and not self.title == title else self.slug
93725180 334
694c4f08
MT
335 # Render and cache HTML
336 html = self.backend.blog._render_text(text, lang=self.lang)
93725180 337
694c4f08 338 self.db.execute("UPDATE blog SET title = %s, slug = %s, text = %s, html = %s, \
93725180 339 tags = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
694c4f08 340 title, slug, text, html, list(tags), self.id)
93725180
MT
341
342 # Update cache
343 self.data.update({
344 "title" : title,
345 "slug" : slug,
346 "text" : text,
d73edae7 347 "html" : html,
93725180
MT
348 "tags" : tags,
349 })
350
351 # Update search index if post is published
352 if self.is_published():
353 self.backend.blog.refresh()
914238a5
MT
354
355 def delete(self):
356 self.db.execute("DELETE FROM blog WHERE id = %s", self.id)
357
358 # Update search indices
359 self.backend.blog.refresh()
023cdd01
MT
360
361
362class PrettyLinksExtension(markdown.extensions.Extension):
363 def extendMarkdown(self, md):
364 md.preprocessors.register(BugzillaLinksPreprocessor(md), "bugzilla", 10)
365 md.preprocessors.register(CVELinksPreprocessor(md), "cve", 10)
366
367
368class BugzillaLinksPreprocessor(markdown.preprocessors.Preprocessor):
a77a9c0a 369 regex = re.compile(r"(?:#(\d{5,}))", re.I)
023cdd01
MT
370
371 def run(self, lines):
372 for line in lines:
373 yield self.regex.sub(r"[#\1](https://bugzilla.ipfire.org/show_bug.cgi?id=\1)", line)
374
375
376class CVELinksPreprocessor(markdown.preprocessors.Preprocessor):
377 regex = re.compile(r"(?:CVE)[\s\-](\d{4}\-\d+)")
378
379 def run(self, lines):
380 for line in lines:
381 yield self.regex.sub(r"[CVE-\1](https://cve.mitre.org/cgi-bin/cvename.cgi?name=\1)", line)