openwrite 0.13.0__tar.gz → 0.13.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {openwrite-0.13.0/openwrite.egg-info → openwrite-0.13.2}/PKG-INFO +2 -1
  2. {openwrite-0.13.0 → openwrite-0.13.2}/README.md +1 -0
  3. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/__init__.py +1 -0
  4. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/routes/admin.py +24 -9
  5. openwrite-0.13.2/openwrite/routes/blog.py +466 -0
  6. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/routes/dashboard.py +254 -58
  7. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/routes/federation.py +90 -197
  8. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/routes/main.py +13 -2
  9. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/style/style.css +6 -0
  10. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/create.html +7 -1
  11. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/dashboard.html +5 -2
  12. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/discover.html +1 -1
  13. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/edit.html +55 -7
  14. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/footer.html +3 -2
  15. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/header.html +0 -1
  16. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/new_page.html +43 -9
  17. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/new_post.html +47 -11
  18. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/post.html +7 -3
  19. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/settings.html +7 -1
  20. openwrite-0.13.2/openwrite/utils/i18n.json +1 -0
  21. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/utils/models.py +1 -0
  22. {openwrite-0.13.0 → openwrite-0.13.2/openwrite.egg-info}/PKG-INFO +2 -1
  23. {openwrite-0.13.0 → openwrite-0.13.2}/pyproject.toml +1 -1
  24. openwrite-0.13.0/openwrite/routes/blog.py +0 -252
  25. openwrite-0.13.0/openwrite/utils/i18n.json +0 -1
  26. {openwrite-0.13.0 → openwrite-0.13.2}/LICENSE +0 -0
  27. {openwrite-0.13.0 → openwrite-0.13.2}/MANIFEST.in +0 -0
  28. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/cli.py +0 -0
  29. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/db/__init__.py +0 -0
  30. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/db/base.py +0 -0
  31. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/gemini_multi.py +0 -0
  32. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/gemini_single.py +0 -0
  33. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/routes/auth.py +0 -0
  34. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/routes/upload.py +0 -0
  35. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/IBMPlexSans-Regular.ttf +0 -0
  36. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/Merriweather.ttf +0 -0
  37. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/avatar.png +0 -0
  38. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/emoji.json +0 -0
  39. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/favicon.ico +0 -0
  40. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/logo.png +0 -0
  41. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/script/chart.js +0 -0
  42. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/script/highlight.min.js +0 -0
  43. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/script/nativeEmoji.js +0 -0
  44. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/script/openwrite.js +0 -0
  45. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/script/papaparse.min.js +0 -0
  46. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/script/showdown.min.js +0 -0
  47. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/script/showdown.min.js.map +0 -0
  48. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/script/site.compat.min.js +0 -0
  49. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/script/site.min.js +0 -0
  50. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/style/hl.css +0 -0
  51. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/style/nativeEmoji.min.css +0 -0
  52. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/style/themes/cyberpunk.css +0 -0
  53. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/style/themes/light.css +0 -0
  54. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/style/themes/obscura.css +0 -0
  55. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/style/themes/stickynote.css +0 -0
  56. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/style/themes/terminal.css +0 -0
  57. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/style/themes/warmnight.css +0 -0
  58. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/static/style/themes/windows98.css +0 -0
  59. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/admin.html +0 -0
  60. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/blog.html +0 -0
  61. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/blog_footer.html +0 -0
  62. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/blog_header.html +0 -0
  63. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/blog_preview.html +0 -0
  64. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/changepw.html +0 -0
  65. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/import.html +0 -0
  66. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/index.html +0 -0
  67. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/instances.html +0 -0
  68. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/login.html +0 -0
  69. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/page_preview.html +0 -0
  70. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/preview.html +0 -0
  71. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/register.html +0 -0
  72. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/stats.html +0 -0
  73. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/templates/translations.html +0 -0
  74. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/utils/create_db.py +0 -0
  75. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/utils/db.py +0 -0
  76. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite/utils/helpers.py +0 -0
  77. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite.egg-info/SOURCES.txt +0 -0
  78. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite.egg-info/dependency_links.txt +0 -0
  79. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite.egg-info/entry_points.txt +0 -0
  80. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite.egg-info/requires.txt +0 -0
  81. {openwrite-0.13.0 → openwrite-0.13.2}/openwrite.egg-info/top_level.txt +0 -0
  82. {openwrite-0.13.0 → openwrite-0.13.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openwrite
3
- Version: 0.13.0
3
+ Version: 0.13.2
4
4
  License: GNU AFFERO GENERAL PUBLIC LICENSE
5
5
  Version 3, 19 November 2007
6
6
 
@@ -688,6 +688,7 @@ Requires-Dist: user-agents
688
688
  Dynamic: license-file
689
689
 
690
690
  ![logo](https://openwrite.io/static/logo.png)
691
+
691
692
  openwrite is a minimalist blogging platform built for writing freely, hosting independently, and publishing without noise.
692
693
 
693
694
  ![PyPI - Downloads](https://img.shields.io/pypi/dw/openwrite)
@@ -1,4 +1,5 @@
1
1
  ![logo](https://openwrite.io/static/logo.png)
2
+
2
3
  openwrite is a minimalist blogging platform built for writing freely, hosting independently, and publishing without noise.
3
4
 
4
5
  ![PyPI - Downloads](https://img.shields.io/pypi/dw/openwrite)
@@ -212,6 +212,7 @@ def create_app(test_config=None):
212
212
 
213
213
  trans = g.db.query(Home).filter(Home.type == "translation", Home.language == lang).all()
214
214
 
215
+ g.version = "0.14.0"
215
216
  g.mode = os.getenv("MODE")
216
217
  f_abs_path = os.path.abspath(__file__)
217
218
  g.mainpath = "/".join(f_abs_path.split("/")[:-1])
@@ -1,6 +1,6 @@
1
1
  from flask import Blueprint, render_template, redirect, g, request, make_response
2
2
  import json
3
- from openwrite.utils.models import Blog, User, Settings, Home
3
+ from openwrite.utils.models import Blog, User, Settings, Home, Post
4
4
  import re
5
5
  import bcrypt
6
6
  from werkzeug.utils import secure_filename
@@ -116,16 +116,16 @@ def admin_add_user():
116
116
 
117
117
  form_username = request.form.get('username')
118
118
  if not re.match(r"^[a-zA-Z0-9](?:[a-zA-Z0-9_-]{1,28}[a-zA-Z0-9])?$", form_username):
119
- return render_template('register.html', error=g.trans['wrong_username'], add="1", captcha="0")
119
+ return render_template('register.html', error='wrong_username', add="1", captcha="0")
120
120
  form_password = request.form.get('password')
121
121
  form_password2 = request.form.get('password2')
122
122
 
123
123
  if form_password != form_password2:
124
- return render_template("register.html", error=g.trans['password_dont_match'], add="1", captcha="0")
124
+ return render_template("register.html", error='password_dont_match', add="1", captcha="0")
125
125
 
126
126
  user = g.db.query(User).filter_by(username=form_username).first()
127
127
  if user:
128
- return render_template('register.html', error=g.trans["user_exists"], add="1", captcha="0")
128
+ return render_template('register.html', error="user_exists", add="1", captcha="0")
129
129
 
130
130
  try:
131
131
  hashed = bcrypt.hashpw(form_password.encode('utf-8'), bcrypt.gensalt())
@@ -136,7 +136,7 @@ def admin_add_user():
136
136
  except Exception as e:
137
137
  print(e)
138
138
  g.db.rollback()
139
- return render_template('register.html', error=g.trans["error"], add="1", captcha="0")
139
+ return render_template('register.html', error='error', add="1", captcha="0")
140
140
 
141
141
 
142
142
  @admin_bp.route("/admin/settings", methods=['GET', 'POST'])
@@ -166,17 +166,17 @@ def admin_settings():
166
166
  file = request.files['logo']
167
167
  filename = secure_filename(file.filename)
168
168
  if "." not in filename:
169
- return render_template("settings.html", settings=settings_dict, hometext=hometext, error="Wrong file!")
169
+ return render_template("settings.html", settings=settings_dict, hometext=hometext, error='invalid_filename')
170
170
 
171
171
  extension = filename.rsplit('.', 1)[1].lower()
172
172
  if extension not in ALLOWED_EXTENSIONS:
173
- return render_template("settings.html", settings=settings_dict, hometext=hometext, error="Wrong extension!")
173
+ return render_template("settings.html", settings=settings_dict, hometext=hometext, error='unsupported_filetype')
174
174
 
175
175
  try:
176
176
  img = Image.open(file.stream)
177
177
  img.verify()
178
178
  except Exception:
179
- return render_template("settings.html", settings=settings_dict, hometext=hometext, error="File is not image!")
179
+ return render_template("settings.html", settings=settings_dict, hometext=hometext, error="not_image")
180
180
 
181
181
  file.stream.seek(0)
182
182
 
@@ -245,4 +245,19 @@ def download_translations():
245
245
  response = make_response(json.dumps(dict(trans_dict), ensure_ascii=False))
246
246
  response.headers["Content-Disposition"] = "attachment; filename=i18n.json"
247
247
  response.headers["Content-Type"] = "application/json"
248
- return response
248
+ return response
249
+
250
+ @admin_bp.route("/admin/remove_from_feed/<int:post_id>")
251
+ def remove_from_feed(post_id):
252
+ """
253
+ Remove a post from the discover feed.
254
+ """
255
+ if g.user is None or g.isadmin == 0 or g.mode == "single":
256
+ return redirect("/")
257
+
258
+ post = g.db.query(Post).filter_by(id=post_id).first()
259
+ if post:
260
+ post.feed = "0"
261
+ g.db.commit()
262
+
263
+ return redirect("/discover")
@@ -0,0 +1,466 @@
1
+ from flask import Blueprint, render_template, redirect, request, g, Response, abort
2
+ from openwrite.utils.models import Blog, Post, User, View, Like, Page
3
+ from openwrite.utils.helpers import gen_link, sanitize_html, anonymize, get_ip
4
+ from feedgen.feed import FeedGenerator
5
+ from sqlalchemy import desc
6
+ import os
7
+ import json
8
+ import re
9
+ from bs4 import BeautifulSoup
10
+ from datetime import timezone, datetime
11
+
12
+ blog_bp = Blueprint("blog", __name__)
13
+
14
+ def create_activity_object(post, blog, url, full_content=True):
15
+ """Create ActivityPub object (Article or Note) based on post content length"""
16
+ dt = datetime.strptime(str(post.date), "%Y-%m-%d %H:%M:%S")
17
+ dt = dt.replace(tzinfo=timezone.utc)
18
+ iso = dt.isoformat(timespec="seconds").replace("+00:00", "Z")
19
+
20
+ clean_content = re.sub(r'<[^>]+>', '', post.content_html)
21
+ content_length = len(clean_content)
22
+
23
+ base_object = {
24
+ "id": f"{url}/{post.link}",
25
+ "attributedTo": url,
26
+ "published": iso,
27
+ "url": f"{url}/{post.link}",
28
+ "to": ["https://www.w3.org/ns/activitystreams#Public"],
29
+ "cc": [f"https://{g.main_domain}/followers/{blog}"],
30
+ "sensitive": False,
31
+ "atomUri": f"{url}/{post.link}",
32
+ "attachment": [],
33
+ "tag": []
34
+ }
35
+
36
+ if content_length > 200:
37
+ words = clean_content.split()[:50]
38
+ summary = ' '.join(words) + '...' if len(words) == 50 else ' '.join(words)
39
+
40
+ if full_content:
41
+ content = f"<h1>{post.title}</h1>{post.content_html}"
42
+ else:
43
+ content = f"<p><a href=\"{url}/{post.link}\">Read full article: {post.title}</a></p>"
44
+
45
+ base_object.update({
46
+ "type": "Article",
47
+ "name": post.title,
48
+ "summary": summary,
49
+ "content": content
50
+ })
51
+ else:
52
+ base_object.update({
53
+ "type": "Note",
54
+ "summary": None,
55
+ "content": f"<h3>{post.title}</h3>{post.content_html}<p><a href=\"{url}/{post.link}\">View post</a></p>"
56
+ })
57
+
58
+ return base_object
59
+
60
+ def create_person_object(blog, url):
61
+ """Create ActivityPub Person object for the blog"""
62
+ published = blog.created
63
+ dt = datetime.strptime(str(published), "%Y-%m-%d %H:%M:%S")
64
+ dt = dt.replace(tzinfo=timezone.utc)
65
+ iso = dt.isoformat(timespec="seconds").replace("+00:00", "Z")
66
+ if blog.fedi_description != "":
67
+ summary = blog.fedi_description
68
+ else:
69
+ summary = f"{blog.name} - Blog on {g.main_domain}"
70
+
71
+ actor = {
72
+ "@context": [
73
+ "https://www.w3.org/ns/activitystreams",
74
+ "https://w3id.org/security/v1",
75
+ {
76
+ "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
77
+ "discoverable": "as:discoverable",
78
+ "indexable": "as:indexable"
79
+ }
80
+ ],
81
+ "id": url,
82
+ "type": "Person",
83
+ "preferredUsername": blog.name,
84
+ "name": blog.title,
85
+ "summary": summary,
86
+ "inbox": f"https://{g.main_domain}/inbox/{blog.name}",
87
+ "outbox": f"https://{g.main_domain}/outbox/{blog.name}",
88
+ "followers": f"https://{g.main_domain}/followers/{blog.name}",
89
+ "published": iso,
90
+ "manuallyApprovesFollowers": False,
91
+ "discoverable": True,
92
+ "indexable": True,
93
+ "url": url,
94
+ "attachment": [
95
+ {
96
+ "type": "PropertyValue",
97
+ "name": "Blog",
98
+ "value": f"<a href=\"{url}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\"><span class=\"\">{url}</span><span class=\"invisible\"></span></a>"
99
+ }
100
+ ],
101
+ "publicKey": {
102
+ "id": f"{url}#main-key",
103
+ "owner": url,
104
+ "publicKeyPem": blog.pub_key
105
+ },
106
+ "icon": {
107
+ "type": "Image",
108
+ "mediaType": "image/png",
109
+ "url": f"https://{g.main_domain}/static/avatar.png"
110
+ }
111
+ }
112
+ return actor
113
+
114
+ def is_activitypub_request():
115
+ """Check if the request is asking for ActivityPub content"""
116
+ accept = request.headers.get('Accept', '')
117
+ return 'application/activity+json' in accept or 'application/ld+json' in accept or 'application/json' in accept
118
+
119
+ @blog_bp.route("/b/<blog>")
120
+ def show_blog(blog):
121
+ if g.mode == "single":
122
+ return redirect("/")
123
+ blog_obj = g.db.query(Blog).filter_by(name=blog).first()
124
+ if blog_obj is None:
125
+ return redirect("/")
126
+
127
+ if blog_obj.access == "domain":
128
+ return redirect(f"https://{blog_obj.name}.{os.getenv('DOMAIN')}/")
129
+
130
+ if is_activitypub_request():
131
+ url = f"https://{g.main_domain}/b/{blog}"
132
+ person = create_person_object(blog_obj, url)
133
+ return Response(json.dumps(person), content_type="application/activity+json", headers={
134
+ 'Cache-Control': 'max-age=3600',
135
+ 'Vary': 'Accept'
136
+ })
137
+
138
+ pages = g.db.query(Page).filter_by(blog=blog_obj.id).all()
139
+ blog_obj.url = f"/b/{blog_obj.name}"
140
+ homepage = g.db.query(Page).filter(Page.blog == blog_obj.id, Page.url == "").first()
141
+ if "{posts}" in homepage.content_raw:
142
+ posts = g.db.query(Post).filter(Post.blog == blog_obj.id, Post.isdraft == "0").order_by(desc(Post.id)).all()
143
+ return render_template("blog.html", blog=blog_obj, page=homepage, posts=posts, pages=pages)
144
+
145
+ return render_template("blog.html", blog=blog_obj, page=homepage, pages=pages)
146
+
147
+ @blog_bp.route("/", subdomain="<blog>")
148
+ def show_subblog(blog):
149
+ if g.mode == "single":
150
+ return redirect("/")
151
+ blog_obj = g.db.query(Blog).filter_by(name=blog).first()
152
+ if blog_obj is None:
153
+ return redirect(f"https://{os.getenv('DOMAIN')}/")
154
+
155
+ if blog_obj.access == "path":
156
+ return redirect(f"https://{os.getenv('DOMAIN')}/b/{blog_obj.name}")
157
+
158
+ if is_activitypub_request():
159
+ url = f"https://{blog}.{g.main_domain}"
160
+ person = create_person_object(blog_obj, url)
161
+ return Response(json.dumps(person), content_type="application/activity+json", headers={
162
+ 'Cache-Control': 'max-age=3600',
163
+ 'Vary': 'Accept'
164
+ })
165
+
166
+ blog_obj.url = f"https://{blog_obj.name}.{os.getenv('DOMAIN')}"
167
+ pages = g.db.query(Page).filter_by(blog=blog_obj.id).all()
168
+ homepage = g.db.query(Page).filter(Page.blog == blog_obj.id, Page.url == "").first()
169
+ if "{posts}" in homepage.content_raw:
170
+ posts = g.db.query(Post).filter(Post.blog == blog_obj.id, Post.isdraft == "0").order_by(desc(Post.id)).all()
171
+ return render_template("blog.html", blog=blog_obj, page=homepage, posts=posts, pages=pages)
172
+
173
+ return render_template("blog.html", blog=blog_obj, page=homepage, pages=pages)
174
+
175
+ @blog_bp.route("/b/<blog>/<post>")
176
+ def show_post(blog, post):
177
+ if g.mode == "single":
178
+ return redirect("/")
179
+ blog_obj = g.db.query(Blog).filter_by(name=blog).first()
180
+ if blog_obj is None:
181
+ return redirect("/")
182
+
183
+ if post == "rss":
184
+ return _generate_rss(blog_obj)
185
+
186
+ if blog_obj.access == "domain":
187
+ return redirect(f"https://{blog_obj.name}.{os.getenv('DOMAIN')}/{post}")
188
+
189
+ blog_obj.url = f"/b/{blog_obj.name}"
190
+ pages = g.db.query(Page).filter_by(blog=blog_obj.id).all()
191
+ one_post = g.db.query(Post).filter(Post.blog == blog_obj.id, Post.link == post, Post.isdraft == "0").first()
192
+
193
+ if not one_post:
194
+ page = g.db.query(Page).filter(Page.blog == blog_obj.id, Page.url == post).first()
195
+ if not page:
196
+ return redirect("/")
197
+
198
+ if "{posts}" in page.content_raw:
199
+ posts = g.db.query(Post).filter(Post.blog == blog_obj.id, Post.isdraft == "0").order_by(desc(Post.id)).all()
200
+ return render_template("blog.html", blog=blog_obj, page=page, posts=posts, pages=pages)
201
+
202
+ return render_template("blog.html", blog=blog_obj, page=page, pages=pages)
203
+
204
+ if is_activitypub_request():
205
+ url = f"https://{g.main_domain}/b/{blog}"
206
+ activity_obj = create_activity_object(one_post, blog, url)
207
+ activity_obj["@context"] = "https://www.w3.org/ns/activitystreams"
208
+ return Response(json.dumps(activity_obj), content_type="application/activity+json", headers={
209
+ 'Cache-Control': 'max-age=3600',
210
+ 'Vary': 'Accept'
211
+ })
212
+
213
+ post_author = g.db.query(User).filter_by(id=blog_obj.owner).first()
214
+ one_post.authorname = post_author.username
215
+ blog_obj.url = f"/b/{blog_obj.name}"
216
+
217
+ user_agent = request.headers.get('User-Agent')
218
+
219
+ if 'Bot' not in user_agent:
220
+ ip = anonymize(get_ip())
221
+ v = g.db.query(View).filter(View.blog == blog_obj.id, View.post == one_post.id, View.hash == ip).count()
222
+ if v < 1:
223
+ now = datetime.now(timezone.utc).replace(microsecond=0)
224
+ new_view = View(blog=blog_obj.id, post=one_post.id, hash=ip, date=now, agent=user_agent)
225
+ g.db.add(new_view)
226
+ g.db.commit()
227
+ likes = g.db.query(Like).filter(Like.blog == blog_obj.id, Like.post == one_post.id).count()
228
+ one_post.likes = likes
229
+ liked = g.db.query(Like).filter(Like.blog == blog_obj.id, Like.post == one_post.id, Like.hash == anonymize(get_ip())).count()
230
+ one_post.liked = liked
231
+
232
+ user = g.db.query(User).filter_by(id=g.user) if g.user else None
233
+ return render_template("post.html", blog=blog_obj, post=one_post, user=user, views=v, likes=likes, pages=pages)
234
+
235
+ @blog_bp.route("/<post>", subdomain="<blog>")
236
+ def show_subpost(blog, post):
237
+ if g.mode == "single":
238
+ return redirect("/")
239
+ blog_obj = g.db.query(Blog).filter_by(name=blog).first()
240
+ if blog_obj is None:
241
+ return redirect(f"https://{os.getenv('DOMAIN')}/")
242
+
243
+ if post == "rss":
244
+ return _generate_rss(blog_obj)
245
+
246
+ if blog_obj.access == "path":
247
+ return redirect(f"https://{os.getenv('DOMAIN')}/b/{blog_obj.name}/{post}")
248
+
249
+ blog_obj.url = f"https://{blog_obj.name}.{os.getenv('DOMAIN')}"
250
+
251
+ pages = g.db.query(Page).filter_by(blog=blog_obj.id).all()
252
+ one_post = g.db.query(Post).filter(Post.blog == blog_obj.id, Post.link == post, Post.isdraft == "0").first()
253
+
254
+ if not one_post:
255
+ page = g.db.query(Page).filter(Page.blog == blog_obj.id, Page.url == post).first()
256
+ if not page:
257
+ return redirect("/")
258
+
259
+ if "{posts}" in page.content_raw:
260
+ posts = g.db.query(Post).filter(Post.blog == blog_obj.id, Post.isdraft == "0").order_by(desc(Post.id)).all()
261
+ return render_template("blog.html", blog=blog_obj, page=page, posts=posts, pages=pages)
262
+
263
+ return render_template("blog.html", blog=blog_obj, page=page, pages=pages)
264
+
265
+ if is_activitypub_request():
266
+ url = f"https://{blog}.{g.main_domain}"
267
+ activity_obj = create_activity_object(one_post, blog, url)
268
+ activity_obj["@context"] = "https://www.w3.org/ns/activitystreams"
269
+ return Response(json.dumps(activity_obj), content_type="application/activity+json", headers={
270
+ 'Cache-Control': 'max-age=3600',
271
+ 'Vary': 'Accept'
272
+ })
273
+
274
+ post_author = g.db.query(User).filter_by(id=blog_obj.owner).first()
275
+ one_post.authorname = post_author.username
276
+ blog_obj.url = f"https://{blog_obj.name}.{os.getenv('DOMAIN')}"
277
+
278
+ user_agent = request.headers.get('User-Agent', '')
279
+ if 'Bot' not in user_agent:
280
+ ip = anonymize(get_ip())
281
+ v = g.db.query(View).filter(View.blog == blog_obj.id, View.post == one_post.id, View.hash == ip).count()
282
+ user_agent = request.headers.get('User-Agent')
283
+ if v < 1:
284
+ now = datetime.now(timezone.utc).replace(microsecond=0)
285
+ new_view = View(blog=blog_obj.id, post=one_post.id, hash=ip, date=now, agent=user_agent)
286
+ g.db.add(new_view)
287
+ g.db.commit()
288
+
289
+ likes = g.db.query(Like).filter(Like.blog == blog_obj.id, Like.post == one_post.id).count()
290
+ one_post.likes = likes
291
+ liked = g.db.query(Like).filter(Like.blog == blog_obj.id, Like.post == one_post.id, Like.hash == anonymize(get_ip())).count()
292
+ one_post.liked = liked
293
+
294
+ user = g.db.query(User).filter_by(id=g.user) if g.user else None
295
+ return render_template("post.html", blog=blog_obj, post=one_post, user=user, views=v, pages=pages)
296
+
297
+ @blog_bp.route("/.well-known/webfinger")
298
+ def webfinger():
299
+ resource = request.args.get("resource")
300
+ if not resource or not resource.startswith("acct:"):
301
+ abort(400)
302
+
303
+ account_part = resource.split(":")[1]
304
+ if "@" not in account_part:
305
+ abort(400)
306
+
307
+ username, domain = account_part.split("@", 1)
308
+
309
+ blog_obj = g.db.query(Blog).filter_by(name=username).first()
310
+ if not blog_obj:
311
+ abort(404)
312
+
313
+ if blog_obj.access == "domain":
314
+ blog_url = f"https://{username}.{g.main_domain}"
315
+ else:
316
+ blog_url = f"https://{g.main_domain}/b/{username}"
317
+
318
+ data = {
319
+ "subject": f"acct:{username}@{g.main_domain}",
320
+ "links": [{
321
+ "rel": "self",
322
+ "type": "application/activity+json",
323
+ "href": blog_url
324
+ }]
325
+ }
326
+
327
+ return Response(
328
+ response=json.dumps(data),
329
+ status=200,
330
+ content_type="application/jrd+json"
331
+ )
332
+
333
+ @blog_bp.route("/.well-known/webfinger", subdomain="<blog>")
334
+ def subdomain_webfinger(blog):
335
+ """Redirect subdomain webfinger requests to main domain"""
336
+ resource = request.args.get("resource")
337
+ if not resource:
338
+ abort(400)
339
+
340
+ if resource.startswith("acct:"):
341
+ account_part = resource.split(":")[1]
342
+ if "@" in account_part:
343
+ username = account_part.split("@")[0]
344
+ corrected_resource = f"acct:{username}@{g.main_domain}"
345
+ return redirect(f"https://{g.main_domain}/.well-known/webfinger?resource={corrected_resource}")
346
+
347
+ return redirect(f"https://{g.main_domain}/.well-known/webfinger?resource={resource}")
348
+
349
+ @blog_bp.route("/p/<post>")
350
+ def single_showpost(post):
351
+ if g.mode == "multi":
352
+ return redirect("/")
353
+
354
+ blog = g.db.query(Blog).filter_by(id=1).first()
355
+ blog.url = f"http://{g.main_domain}"
356
+ one_post = g.db.query(Post).filter(Post.blog == 1, Post.link == post, Post.isdraft == "0").first()
357
+
358
+ pages = g.db.query(Page).filter_by(blog=1).all()
359
+
360
+ if not one_post:
361
+ page = g.db.query(Page).filter(Page.blog == blog.id, Page.url == post).first()
362
+ if not page:
363
+ return redirect("/")
364
+
365
+ if "{posts}" in page.content_raw:
366
+ posts = g.db.query(Post).filter(Post.blog == blog.id, Post.isdraft == "0").order_by(desc(Post.id)).all()
367
+
368
+ return render_template("blog.html", blog=blog, page=page, posts=posts, pages=pages)
369
+
370
+ return render_template("blog.html", blog=blog, page=page, pages=pages)
371
+
372
+ if is_activitypub_request():
373
+ url = f"https://{g.main_domain}"
374
+ activity_obj = create_activity_object(one_post, "default", url)
375
+ activity_obj["@context"] = "https://www.w3.org/ns/activitystreams"
376
+ return Response(json.dumps(activity_obj), content_type="application/activity+json", headers={
377
+ 'Cache-Control': 'max-age=3600',
378
+ 'Vary': 'Accept'
379
+ })
380
+
381
+ post_author = g.db.query(User).filter_by(id=1).first()
382
+ one_post.authorname = post_author.username
383
+
384
+ user_agent = request.headers.get('User-Agent', '')
385
+ if 'Bot' not in user_agent:
386
+ ip = anonymize(get_ip())
387
+ v = g.db.query(View).filter(View.blog == 1, View.post == one_post.id, View.hash == ip).count()
388
+ user_agent = request.headers.get('User-Agent')
389
+ if v < 1:
390
+ now = datetime.now(timezone.utc).replace(microsecond=0)
391
+ new_view = View(blog=1, post=one_post.id, hash=ip, date=now, agent=user_agent)
392
+ g.db.add(new_view)
393
+ g.db.commit()
394
+
395
+ likes = g.db.query(Like).filter(Like.blog == 1, Like.post == one_post.id).count()
396
+ one_post.likes = likes
397
+ liked = g.db.query(Like).filter(Like.blog == 1, Like.post == one_post.id, Like.hash == anonymize(get_ip())).count()
398
+ one_post.liked = liked
399
+
400
+ user = g.db.query(User).filter_by(id=g.user) if g.user else None
401
+ return render_template("post.html", blog=blog, post=one_post, user=user, views=v, pages=pages)
402
+
403
+ @blog_bp.route("/rss")
404
+ def single_rss():
405
+ if g.mode == "multi":
406
+ return redirect("/")
407
+
408
+ blog = g.db.query(Blog).first()
409
+ return _generate_rss(blog)
410
+
411
+ @blog_bp.route("/like", methods=["POST"])
412
+ def like():
413
+ data = request.get_json()
414
+ if not data:
415
+ abort(400)
416
+
417
+ blog_id = data.get("blog")
418
+ post_id = data.get("post")
419
+ post = g.db.query(Post).filter(Post.blog == blog_id, Post.id == post_id).count()
420
+ if post < 1:
421
+ resp = {"status": "no_post"}
422
+ return resp, 404
423
+ ip = anonymize(get_ip())
424
+
425
+ l = g.db.query(Like).filter(Like.blog == blog_id, Like.post == post_id, Like.hash == ip).first()
426
+ if l:
427
+ g.db.delete(l)
428
+ g.db.commit()
429
+ resp = {"status": "deleted"}
430
+ status = 204
431
+ else:
432
+ now = datetime.now(timezone.utc).replace(microsecond=0)
433
+ like = Like(blog=blog_id, post=post_id, hash=ip, date=now)
434
+ g.db.add(like)
435
+ g.db.commit()
436
+ resp = {"status": "ok"}
437
+ status = 201
438
+ return resp, status
439
+
440
+
441
+ def _generate_rss(blog):
442
+ posts = g.db.query(Post).filter(Post.blog == blog.id, Post.isdraft == "0").all()
443
+ fg = FeedGenerator()
444
+ fg.title(blog.title)
445
+
446
+ if blog.access == "domain":
447
+ blog_url = f"https://{blog.name}.{g.main_domain}"
448
+ post_base_url = blog_url
449
+ else:
450
+ blog_url = f"https://{g.main_domain}/b/{blog.name}"
451
+ post_base_url = blog_url
452
+
453
+ fg.link(href=blog_url, rel="alternate")
454
+ fg.description(blog.description_html)
455
+
456
+ for p in posts:
457
+ soup = BeautifulSoup(p.content_html, "html.parser")
458
+ fe = fg.add_entry()
459
+ fe.title(p.title)
460
+ fe.link(href=f"{post_base_url}/{p.link}")
461
+ fe.description(soup.get_text())
462
+ fe.published(p.date.replace(tzinfo=timezone.utc))
463
+
464
+ return Response(fg.rss_str(pretty=True).decode("utf-8"), mimetype="application/rss+xml")
465
+
466
+