listssrht: Use GraphQL for list CRUD operations

This commit is contained in:
Adnan Maolood 2022-06-14 05:47:07 -04:00 committed by Drew DeVault
parent 17d52d7b3f
commit 2ae65a9e81
8 changed files with 228 additions and 116 deletions

View File

@ -1,4 +1,4 @@
from flask import Blueprint, abort, request
from flask import current_app, Blueprint, abort, request
from listssrht.blueprints.api import get_user, get_list
from listssrht.blueprints.archives import apply_search
from listssrht.types import List, Email, ListAccess, Subscription
@ -6,6 +6,7 @@ from listssrht.webhooks import ListWebhook, UserWebhook
from sqlalchemy import or_
from srht.api import paginated_response
from srht.database import db
from srht.graphql import exec_gql
from srht.oauth import oauth, current_token
from srht.validation import Validation
@ -28,23 +29,63 @@ def user_lists_GET(username):
def user_lists_POST():
user = current_token.user
valid = Validation(request)
ml = List(user, valid)
name = valid.require("name", friendly_name="Name")
description = valid.optional("description")
if not valid.ok:
return valid.response
db.session.add(ml)
db.session.flush()
UserWebhook.deliver(UserWebhook.Events.list_create,
ml.to_dict(), UserWebhook.Subscription.user_id == ml.owner_id)
db.session.commit()
# Auto-subscribe the owner
sub = Subscription()
sub.user_id = user.id
sub.list_id = ml.id
db.session.add(sub)
db.session.commit()
resp = exec_gql(current_app.site, """
mutation CreateMailingList($name: String!, $description: String) {
createMailingList(name: $name, description: $description) {
id
name
owner {
canonical_name: canonicalName
... on User {
name: username
}
}
created
updated
description
nonsubscriber {
browse
reply
post
}
subscriber {
browse
reply
post
}
identified {
browse
reply
post
}
}
}
""", user=user, valid=valid, name=name, description=description)
return ml.to_dict(), 201
if not valid.ok:
return valid.response
resp = resp["createMailingList"]
permList = lambda acl: [key for key in [
"browse", "reply", "post"
] if acl[key]]
resp["permissions"] = {
"nonsubscriber": permList(resp["nonsubscriber"]),
"subscriber": permList(resp["subscriber"]),
"account": permList(resp["identified"]),
}
del resp["nonsubscriber"]
del resp["subscriber"]
del resp["identified"]
return resp, 201
@lists.route("/api/user/<username>/lists/<list_name>")
@lists.route("/api/lists/<list_name>", defaults={"username": None})
@ -78,12 +119,67 @@ def user_lists_by_name_PUT(list_name):
user, ml, access = get_list(None, list_name)
if ml.owner_id != user.id:
abort(403)
valid = Validation(request)
ml.update(valid)
ListWebhook.deliver(ListWebhook.Events.list_update,
ml.to_dict(), ListWebhook.Subscription.list_id == ml.id)
db.session.commit()
return ml.to_dict()
rewrite = lambda value: None if value == "" else value
input = {
key: rewrite(valid.source[key]) for key in [
"description"
] if valid.source.get(key) is not None
}
resp = exec_gql(current_app.site, """
mutation UpdateMailingList($id: Int!, $input: MailingListInput!) {
updateMailingList(id: $id, input: $input) {
id
name
owner {
canonical_name: canonicalName
... on User {
name: username
}
}
created
updated
description
nonsubscriber {
browse
reply
post
}
subscriber {
browse
reply
post
}
identified {
browse
reply
post
}
}
}
""", user=user, valid=valid, id=ml.id, input=input)
if not valid.ok:
return valid.response
resp = resp["updateMailingList"]
permList = lambda acl: [key for key in [
"browse", "reply", "post"
] if acl[key]]
resp["permissions"] = {
"nonsubscriber": permList(resp["nonsubscriber"]),
"subscriber": permList(resp["subscriber"]),
"account": permList(resp["identified"]),
}
del resp["nonsubscriber"]
del resp["subscriber"]
del resp["identified"]
return resp
@lists.route("/api/lists/<list_name>", methods=["DELETE"])
@oauth("lists:write")
@ -91,10 +187,11 @@ def user_lists_by_name_DELETE(list_name):
user, ml, access = get_list(None, list_name)
if ml.owner_id != user.id:
abort(403)
ListWebhook.deliver(ListWebhook.Events.list_delete,
ml.to_dict(), ListWebhook.Subscription.list_id == ml.id)
db.engine.execute(f"DELETE FROM list WHERE id = {ml.id};")
db.session.commit()
exec_gql(current_app.site, """
mutation DeleteMailingList($id: Int!) {
deleteMailingList(id: $id) { id }
}
""", user=user, id=ml.id)
return {}, 204
@lists.route("/api/user/<username>/lists/<list_name>/posts")

View File

@ -3,6 +3,7 @@ from flask import current_app, send_file, session
from srht.config import cfg
from srht.database import db
from srht.flask import paginate_query
from srht.graphql import exec_gql
from srht.oauth import current_user, loginrequired
from srht.validation import Validation
from listssrht.blueprints.archives import get_list
@ -47,23 +48,27 @@ def info_POST(owner_name, list_name):
abort(403)
valid = Validation(request)
list_desc = valid.optional("list_desc")
if list_desc == "":
list_desc = None
valid.expect(not list_desc or len(list_desc) < 2048,
"Description must be between 16 and 2048 characters.",
field="list_desc")
rewrite = lambda value: None if value == "" else value
input = {
key: rewrite(valid.source[key]) for key in [
"description"
] if valid.source.get(key) is not None
}
exec_gql(current_app.site, """
mutation UpdateMailingList($id: Int!, $input: MailingListInput!) {
updateMailingList(id: $id, input: $input) {
id
}
}
""", valid=valid, id=ml.id, input=input)
if not valid.ok:
return render_template("settings-info.html", list=ml, owner=owner,
access_type_list=ListAccess, access_help_map=access_help_map,
view="info", **valid.kwargs)
ml.description = list_desc
ListWebhook.deliver(ListWebhook.Events.list_update,
ml.to_dict(), ListWebhook.Subscription.list_id == ml.id)
db.session.commit()
return redirect(url_for("archives.archive",
return redirect(url_for("settings.info_GET",
owner_name=owner_name, list_name=list_name))
@settings.route("/<owner_name>/<list_name>/settings/access")
@ -98,19 +103,26 @@ def access_POST(owner_name, list_name):
abort(403)
valid = Validation(request)
access = _process_access(valid, "default")
input = {
perm: ((access & ListAccess[perm].value) != 0) for perm in [
"browse", "reply", "post", "moderate",
]
}
ml.nonsubscriber_permissions = _process_access(valid, "nonsub")
ml.subscriber_permissions = _process_access(valid, "sub")
ml.account_permissions = _process_access(valid, "account")
if ListAccess.browse in ml.nonsubscriber_permissions:
ml.subscriber_permissions |= ListAccess.browse
ml.account_permissions |= ListAccess.browse
ml.subscriber_permissions |= ml.nonsubscriber_permissions
ml.account_permissions |= ml.nonsubscriber_permissions
exec_gql(current_app.site, """
mutation UpdateMailingListACL($id: Int!, $input: ACLInput!) {
updateMailingListACL(listID: $id, input: $input) {
id
}
}
""", valid=valid, id=ml.id, input=input)
if not valid.ok:
return render_template("settings-access.html", view="access",
ml=ml, owner=owner, access_type_list=ListAccess,
access_help_map=access_help_map, **valid.kwargs)
ListWebhook.deliver(ListWebhook.Events.list_update,
ml.to_dict(), ListWebhook.Subscription.list_id == ml.id)
db.session.commit()
return redirect(url_for("settings.access_GET",
owner_name=owner_name, list_name=list_name))
@ -216,10 +228,27 @@ def content_POST(owner_name, list_name):
abort(403)
valid = Validation(request)
ml.permit_mimetypes = valid.optional("permit_mimetypes")
ml.reject_mimetypes = valid.optional("reject_mimetypes")
rewrite = lambda value: [] if value == "" else value.split(",")
input = {
key: rewrite(valid.source[key]) for key in [
"permitMime", "rejectMime"
] if valid.source.get(key) is not None
}
exec_gql(current_app.site, """
mutation UpdateMailingList($id: Int!, $input: MailingListInput!) {
updateMailingList(id: $id, input: $input) {
id
}
}
""", valid=valid, id=ml.id, input=input)
if not valid.ok:
return render_template("settings-content.html",
view="content", ml=ml, owner=owner,
always_reject=list(filter(None, cfg("lists.sr.ht::worker", "reject-mimetypes").split(","))),
**valid.kwargs)
db.session.commit()
return redirect(url_for("settings.content_GET",
owner_name=owner_name, list_name=list_name))

View File

@ -1,11 +1,12 @@
from email.mime.text import MIMEText
from email.utils import parseaddr, formatdate, make_msgid
from flask import Blueprint, render_template, request, redirect, url_for, abort
from flask import current_app, Blueprint, render_template, request, redirect, url_for, abort
from flask import session
from srht.config import cfg, cfgi
from srht.database import db
from srht.oauth import UserType, current_user, loginrequired
from srht.flask import paginate_query
from srht.graphql import exec_gql
from srht.search import search_by
from srht.validation import Validation
from sqlalchemy import or_
@ -108,24 +109,29 @@ def create_list_POST():
abort(401)
valid = Validation(request)
ml = List(current_user, valid)
name = valid.require("name", friendly_name="Name")
description = valid.optional("description")
if not valid.ok:
return render_template("create.html", **valid.kwargs)
db.session.add(ml)
db.session.flush()
UserWebhook.deliver(UserWebhook.Events.list_create,
ml.to_dict(), UserWebhook.Subscription.user_id == ml.owner_id)
# Auto-subscribe the owner
sub = Subscription()
sub.user_id = current_user.id
sub.list_id = ml.id
db.session.add(sub)
db.session.commit()
resp = exec_gql(current_app.site, """
mutation CreateMailingList($name: String!, $description: String) {
createMailingList(name: $name, description: $description) {
name
owner {
canonicalName
}
}
}
""", valid=valid, name=name, description=description)
if not valid.ok:
return render_template("create.html", **valid.kwargs)
resp = resp["createMailingList"]
return redirect(url_for("archives.archive",
owner_name=current_user.canonical_name,
list_name=ml.name))
owner_name=resp["owner"]["canonicalName"],
list_name=resp["name"]))
@user.route("/lists/create-mirror")
@loginrequired

View File

@ -1,5 +1,6 @@
from srht.config import cfg, cfgi
from srht.database import DbSession, db
from srht.graphql import exec_gql
if not hasattr(db, "session"):
db = DbSession(cfg("lists.sr.ht", "connection-string"))
import listssrht.types
@ -707,9 +708,9 @@ def forward_thread(list_id, thread_id, recipient):
@task
def delete_list(list_id):
from listssrht.webhooks import ListWebhook
ml = List.query.filter(List.id == list_id).one_or_none()
ListWebhook.deliver(ListWebhook.Events.list_delete,
ml.to_dict(), ListWebhook.Subscription.list_id == ml.id)
db.engine.execute(f"DELETE FROM list WHERE id = {ml.id};")
db.session.commit()
exec_gql("lists.sr.ht", """
mutation DeleteMailingList($id: Int!) {
deleteMailingList(id: $id) { id }
}
""", user=ml.owner, id=ml.id)

View File

@ -29,6 +29,7 @@
>{{description or ""}}</textarea>
{{valid.summary("description")}}
</div>
{{valid.summary()}}
<button type="submit" class="btn btn-primary">
Create {{icon("caret-right")}}
</button>

View File

@ -55,37 +55,15 @@
</p>
<div class="event-list">
<div class="event">
<h4>Non-subscriber Permissions</h4>
<h4>Default Permissions</h4>
<p>
Permissions granted to users who are not subscribed or logged
in to a sr.ht account. Any permission granted to anonymous
users are granted to subscribers and account holders as well,
unless explicitly revoked from that user.
These permissions are used for anyone who does not have a more
specific access configuration.
</p>
{% for a in access_type_list %}
{{ perm_checkbox(a, ml.nonsubscriber_permissions , "nonsub") }}
{{ perm_checkbox(a, ml.nonsubscriber_permissions , "default") }}
{% endfor %}
{{ valid.summary("list_nonsubscriber_access") }}
</div>
<div class="event">
<h4>Subscriber Permissions</h4>
<p>
Permissions granted to users who are subscribed to the list.
</p>
{% for a in access_type_list %}
{{ perm_checkbox(a, ml.subscriber_permissions , "sub") }}
{% endfor %}
{{ valid.summary("list_subscriber_access") }}
</div>
<div class="event">
<h4>Account Holder Permissions</h4>
<p>
Permissions granted to logged in holders of sr.ht accounts.
</p>
{% for a in access_type_list %}
{{ perm_checkbox(a, ml.account_permissions, "account") }}
{% endfor %}
{{ valid.summary("list_account_access") }}
{{ valid.summary("list_default_access") }}
</div>
</div>
</div>

View File

@ -11,18 +11,18 @@
<form method="POST">
{{csrf_token()}}
<div class="form-group">
<label for="permit_mimetypes">
<label for="permitMime">
Permitted mimetypes
</label>
<input
type="text"
name="permit_mimetypes"
id="permit_mimetypes"
name="permitMime"
id="permitMime"
class="form-control"
value="{{ ml.permit_mimetypes }}"
aria-describedby="permit_mimetypes-help" />
aria-describedby="permitMime-help" />
<p
id="permit_mimetypes-help"
id="permitMime-help"
class="form-text text-muted"
>
Comma separated list of mimetypes, <a
@ -31,21 +31,21 @@
target="_blank"
>fnmatch</a> for wildcards.
</p>
{{ valid.summary("permit_mimetypes") }}
{{ valid.summary("permitMime") }}
</div>
<div class="form-group">
<label for="reject_mimetypes">
<label for="rejectMime">
Rejected mimetypes
</label>
<input
type="text"
name="reject_mimetypes"
id="reject_mimetypes"
name="rejectMime"
id="rejectMime"
class="form-control"
value="{{ ml.reject_mimetypes }}"
aria-describedby="reject_mimetypes-help" />
aria-describedby="rejectMime-help" />
<p
id="reject_mimetypes-help"
id="rejectMime-help"
class="form-text text-muted"
>
Comma separated list of mimetypes, <a
@ -66,7 +66,7 @@
always rejected.
{% endif %}
</p>
{{ valid.summary("reject_mimetypes") }}
{{ valid.summary("rejectMime") }}
</div>
{{ valid.summary() }}
<span class="pull-right">

View File

@ -11,29 +11,29 @@
<form method="POST">
{{csrf_token()}}
<div class="form-group">
<label for="list_name">
<label for="name">
Name
<span class="text-muted">(you can't edit this)</p>
</label>
<input
type="text"
name="list_name"
id="list_name"
name="name"
id="name"
class="form-control"
value="{{ ml.name }}"
disabled />
</div>
<div class="form-group {{valid.cls("list_desc")}}">
<label for="list_desc">Description</label>
<div class="form-group {{valid.cls("description")}}">
<label for="description">Description</label>
<textarea
name="list_desc"
id="list_desc"
name="description"
id="description"
class="form-control"
placeholder="Markdown supported"
rows="10"
aria-describedby="list_desc-help"
>{{list_desc or ml.description or ""}}</textarea>
{{ valid.summary("list_desc") }}
aria-describedby="description-help"
>{{description or ml.description or ""}}</textarea>
{{ valid.summary("description") }}
</div>
{{ valid.summary() }}
<span class="pull-right">