Compare commits

...

22 Commits

Author SHA1 Message Date
4f4a71bf72 fix issue in object comment visibility
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2024-12-30 20:55:12 +00:00
239adc4816 Feature flag for groups 2024-12-30 20:38:09 +00:00
15bfb43965 feature flag user discoverability 2024-12-30 20:17:37 +00:00
384cf75400 Feature flag control group discoverability 2024-12-30 19:49:27 +00:00
89ffa94553 put account following behind a feature flag 2024-12-30 18:07:50 +00:00
e03ceed668 Move online safety notice to Wiki 2024-12-30 11:21:57 +00:00
31d6f41276 First draft started online safety notice 2024-12-29 23:50:06 +00:00
fe24bcef1e Allow group comment replies to be moderated: 2024-12-29 22:40:06 +00:00
29d0af6e5b Allow group forum replies to be moderated 2024-12-29 22:23:19 +00:00
045a0af4a2 show group entry attachments in root moderation UI 2024-12-29 21:18:09 +00:00
8446c209b3 Add ability to moderate group entries 2024-12-29 20:17:04 +00:00
c6fdc1d537 Allow group content to be moderated 2024-12-29 19:45:07 +00:00
397ec5072b Add dedicated report component 2024-12-29 19:40:16 +00:00
82f0a1eb6d Add support for moderation emails for user updates 2024-12-29 19:11:13 +00:00
e174abce33 Report buttons on user profiles and group content 2024-12-28 23:47:09 +00:00
d72038212f Add a report page 2024-12-28 23:25:48 +00:00
957cbebdd2 allow comment notifications to be delayed until after moderation 2024-12-28 21:53:37 +00:00
fdb363abe4 Allow object comments to be moderated 2024-12-28 21:33:11 +00:00
859d78cf5d Send alert email when moderation is needed 2024-12-28 19:19:39 +00:00
f0a0a55bce Allow for publish of objects after moderation 2024-12-28 19:10:48 +00:00
0019f4e019 Merge branch 'project-moderation' of git.wilw.dev:wilw/treadl into project-moderation 2024-12-28 16:00:03 +00:00
af07226227 Add basic moderation checks for project objects 2024-12-28 15:27:04 +00:00
29 changed files with 632 additions and 169 deletions

View File

@ -102,6 +102,11 @@ def update(user, id, update):
]
updater = util.build_updater(update, allowed_keys)
if updater:
if "$set" in updater and (
"name" in update or "description" in update or "image" in update
):
updater["$set"]["moderationRequired"] = True
util.send_moderation_request(user, "groups", group)
db.groups.update_one({"_id": id}, updater)
return get_one(user, id)
@ -137,6 +142,7 @@ def create_entry(user, id, data):
"group": id,
"user": user["_id"],
"content": data["content"],
"moderationRequired": True,
}
if "attachments" in data:
entry["attachments"] = data["attachments"]
@ -161,12 +167,24 @@ def create_entry(user, id, data):
entry["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(user["_id"], user["avatar"])
)
util.send_moderation_request(user, "groupEntries", entry)
return entry
def send_entry_notification(id):
db = database.get_db()
entry = db.groupEntries.find_one({"_id": ObjectId(id)})
# If this is a reply, then send the reply email instead
if entry.get("inReplyTo"):
return send_entry_reply_notification(id)
group = db.groups.find_one({"_id": entry["group"]})
user = db.users.find_one({"_id": entry["user"]})
for u in db.users.find(
{
"_id": {"$ne": user["_id"]},
"groups": id,
"subscriptions.email": "groupFeed-" + str(id),
"groups": group["_id"],
"subscriptions.email": "groupFeed-" + str(group["_id"]),
},
{"email": 1, "username": 1},
):
@ -178,18 +196,17 @@ def create_entry(user, id, data):
u["username"],
user["username"],
group["name"],
data["content"],
"{}/groups/{}".format(APP_URL, str(id)),
entry["content"],
"{}/groups/{}".format(APP_URL, str(group["_id"])),
APP_NAME,
),
}
)
push.send_multiple(
list(db.users.find({"_id": {"$ne": user["_id"]}, "groups": id})),
list(db.users.find({"_id": {"$ne": user["_id"]}, "groups": group["_id"]})),
"{} posted in {}".format(user["username"], group["name"]),
data["content"][:30] + "...",
entry["content"][:30] + "...",
)
return entry
def get_entries(user, id):
@ -202,8 +219,14 @@ def get_entries(user, id):
raise util.errors.BadRequest("You're not a member of this group")
if not has_group_permission(user, group, "viewNoticeboard"):
raise util.errors.Forbidden("You don't have permission to view the feed")
# Only return entries that have been moderated or are owned by the user
entries = list(
db.groupEntries.find({"group": id}).sort("createdAt", pymongo.DESCENDING)
db.groupEntries.find(
{
"group": id,
"$or": [{"user": user["_id"]}, {"moderationRequired": {"$ne": True}}],
}
).sort("createdAt", pymongo.DESCENDING)
)
authors = list(
db.users.find(
@ -266,6 +289,7 @@ def create_entry_reply(user, id, entry_id, data):
"inReplyTo": entry_id,
"user": user["_id"],
"content": data["content"],
"moderationRequired": True,
}
if "attachments" in data:
reply["attachments"] = data["attachments"]
@ -290,9 +314,22 @@ def create_entry_reply(user, id, entry_id, data):
reply["authorUser"]["avatarUrl"] = uploads.get_presigned_url(
"users/{0}/{1}".format(user["_id"], user["avatar"])
)
util.send_moderation_request(user, "groupEntries", entry)
return reply
def send_entry_reply_notification(id):
db = database.get_db()
reply = db.groupEntries.find_one({"_id": ObjectId(id)})
user = db.users.find_one({"_id": reply["user"]})
original_entry = db.groupEntries.find_one({"_id": reply["inReplyTo"]})
group = db.groups.find_one({"_id": original_entry["group"]})
op = db.users.find_one(
{
"$and": [{"_id": entry.get("user")}, {"_id": {"$ne": user["_id"]}}],
"$and": [
{"_id": original_entry.get("user")},
{"_id": {"$ne": user["_id"]}},
],
"subscriptions.email": "messages.replied",
}
)
@ -305,13 +342,12 @@ def create_entry_reply(user, id, entry_id, data):
op["username"],
user["username"],
group["name"],
data["content"],
"{}/groups/{}".format(APP_URL, str(id)),
reply["content"],
"{}/groups/{}".format(APP_URL, str(group["_id"])),
APP_NAME,
),
}
)
return reply
def delete_entry_reply(user, id, entry_id, reply_id):
@ -594,6 +630,7 @@ def create_forum_topic_reply(user, id, topic_id, data):
"user": user["_id"],
"content": data["content"],
"attachments": data.get("attachments", []),
"moderationRequired": True,
}
result = db.groupForumTopicReplies.insert_one(reply)
db.groupForumTopics.update_one(
@ -631,11 +668,21 @@ def create_forum_topic_reply(user, id, topic_id, data):
)
)
util.send_moderation_request(user, "groupForumTopicReplies", reply)
return reply
def send_forum_topic_reply_notification(id):
db = database.get_db()
reply = db.groupForumTopicReplies.find_one({"_id": ObjectId(id)})
user = db.users.find_one({"_id": reply["user"]})
topic = db.groupForumTopics.find_one({"_id": reply["topic"]})
group = db.groups.find_one({"_id": topic["group"]})
for u in db.users.find(
{
"_id": {"$ne": user["_id"]},
"groups": id,
"subscriptions.email": "groupForumTopic-" + str(topic_id),
"_id": {"$ne": reply["user"]},
"groups": topic["group"],
"subscriptions.email": "groupForumTopic-" + str(topic["_id"]),
},
{"email": 1, "username": 1},
):
@ -648,17 +695,15 @@ def create_forum_topic_reply(user, id, topic_id, data):
user["username"],
topic["title"],
group["name"],
data["content"],
reply["content"],
"{}/groups/{}/forum/topics/{}".format(
APP_URL, str(id), str(topic_id)
APP_URL, str(group["_id"]), str(topic["_id"])
),
APP_NAME,
),
}
)
return reply
def get_forum_topic_replies(user, id, topic_id, data):
REPLIES_PER_PAGE = 20
@ -678,7 +723,12 @@ def get_forum_topic_replies(user, id, topic_id, data):
)
total_replies = db.groupForumTopicReplies.count_documents({"topic": topic_id})
replies = list(
db.groupForumTopicReplies.find({"topic": topic_id})
db.groupForumTopicReplies.find(
{
"topic": topic_id,
"$or": [{"moderationRequired": {"$ne": True}}, {"user": user["_id"]}],
}
)
.sort("createdAt", pymongo.ASCENDING)
.skip((page - 1) * REPLIES_PER_PAGE)
.limit(REPLIES_PER_PAGE)

View File

@ -34,7 +34,9 @@ def get(user, id):
raise util.errors.NotFound("Project not found")
is_owner = user and (user.get("_id") == proj["user"])
if not is_owner and proj["visibility"] != "public":
raise util.errors.BadRequest("Forbidden")
raise util.errors.Forbidden("Forbidden")
if not util.can_edit_project(user, proj) and obj.get("moderationRequired"):
raise util.errors.Forbidden("Awaiting moderation")
owner = db.users.find_one({"_id": proj["user"]}, {"username": 1, "avatar": 1})
if obj["type"] == "file" and "storedName" in obj:
obj["url"] = uploads.get_presigned_url(
@ -169,12 +171,12 @@ def create_comment(user, id, data):
obj = db.objects.find_one({"_id": ObjectId(id)})
if not obj:
raise util.errors.NotFound("We could not find the specified object")
project = db.projects.find_one({"_id": obj["project"]})
comment = {
"content": data.get("content", ""),
"object": ObjectId(id),
"user": user["_id"],
"createdAt": datetime.datetime.now(),
"moderationRequired": True,
}
result = db.comments.insert_one(comment)
db.objects.update_one({"_id": ObjectId(id)}, {"$inc": {"commentCount": 1}})
@ -186,6 +188,16 @@ def create_comment(user, id, data):
"users/{0}/{1}".format(user["_id"], user.get("avatar"))
),
}
util.send_moderation_request(user, "comments", comment)
return comment
def send_comment_notification(id):
db = database.get_db()
comment = db.comments.find_one({"_id": ObjectId(id)})
user = db.users.find_one({"_id": comment["user"]})
obj = db.objects.find_one({"_id": comment["object"]})
project = db.projects.find_one({"_id": obj["project"]})
project_owner = db.users.find_one(
{"_id": project["user"], "subscriptions.email": "projects.commented"}
)
@ -209,7 +221,6 @@ def create_comment(user, id, data):
),
}
)
return comment
def get_comments(user, id):
@ -224,7 +235,14 @@ def get_comments(user, id):
is_owner = user and (user.get("_id") == proj["user"])
if not is_owner and proj["visibility"] != "public":
raise util.errors.Forbidden("This project is private")
comments = list(db.comments.find({"object": id}))
query = {
"object": id,
"$or": [
{"moderationRequired": {"$ne": True}},
{"user": user["_id"] if user else None},
],
}
comments = list(db.comments.find(query))
user_ids = list(map(lambda c: c["user"], comments))
users = list(
db.users.find({"_id": {"$in": user_ids}}, {"username": 1, "avatar": 1})

View File

@ -241,9 +241,12 @@ def get_objects(user, username, path):
if not util.can_view_project(user, project):
raise util.errors.Forbidden("This project is private")
query = {"project": project["_id"]}
if not util.can_edit_project(user, project):
query["moderationRequired"] = {"$ne": True}
objs = list(
db.objects.find(
{"project": project["_id"]},
query,
{
"createdAt": 1,
"name": 1,
@ -295,6 +298,7 @@ def create_object(user, username, path, data):
"storedName": data["storedName"],
"createdAt": datetime.datetime.now(),
"type": "file",
"moderationRequired": True,
}
if re.search(r"(.jpg)|(.png)|(.jpeg)|(.gif)$", data["storedName"].lower()):
obj["isImage"] = True
@ -313,6 +317,7 @@ def create_object(user, username, path, data):
uploads.blur_image(
"projects/" + str(project["_id"]) + "/" + data["storedName"], handle_cb
)
util.send_moderation_request(user, "object", obj)
return obj
if data["type"] == "pattern":
obj = {

View File

@ -1,5 +1,7 @@
import datetime
from bson.objectid import ObjectId
from util import database, util
from api import uploads
from api import uploads, objects, groups
def get_users(user):
@ -52,3 +54,82 @@ def get_groups(user):
for group in groups:
group["memberCount"] = db.users.count_documents({"groups": group["_id"]})
return {"groups": groups}
def get_moderation(user):
db = database.get_db()
if not util.is_root(user):
raise util.errors.Forbidden("Not allowed")
object_list = list(db.objects.find({"moderationRequired": True}))
for obj in object_list:
if obj["type"] == "file" and "storedName" in obj:
obj["url"] = uploads.get_presigned_url(
"projects/{0}/{1}".format(obj["project"], obj["storedName"])
)
comment_list = list(db.comments.find({"moderationRequired": True}))
user_list = list(db.users.find({"moderationRequired": True}, {"username": 1}))
group_list = list(db.groups.find({"moderationRequired": True}, {"name": 1}))
group_entry_list = list(db.groupEntries.find({"moderationRequired": True}))
for entry in group_entry_list:
for a in entry.get("attachments", []):
if a["type"] == "file" and "storedName" in a:
a["url"] = uploads.get_presigned_url(
"groups/{0}/{1}".format(entry["group"], a["storedName"])
)
group_topic_reply_list = list(
db.groupForumTopicReplies.find({"moderationRequired": True})
)
for reply in group_topic_reply_list:
for a in reply.get("attachments", []):
if a["type"] == "file" and "storedName" in a:
a["url"] = uploads.get_presigned_url(
"groups/{0}/topics/{1}/{2}".format(
reply["group"], reply["topic"], a["storedName"]
)
)
return {
"objects": object_list,
"comments": comment_list,
"users": user_list,
"groups": group_list,
"groupEntries": group_entry_list,
"groupForumTopicReplies": group_topic_reply_list,
}
def moderate(user, item_type, item_id, allowed):
db = database.get_db()
if not util.is_root(user):
raise util.errors.Forbidden("Not allowed")
if item_type not in [
"objects",
"comments",
"users",
"groups",
"groupEntries",
"groupForumTopicReplies",
]:
raise util.errors.BadRequest("Invalid item type")
item_id = ObjectId(item_id)
item = db[item_type].find_one({"_id": item_id})
# For now, handle only allowed moderations.
# Disallowed will be manually managed.
if item and allowed:
db[item_type].update_one(
{"_id": item_id},
{
"$set": {
"moderationRequired": False,
"moderated": True,
"moderatedAt": datetime.datetime.now(),
"moderatedBy": user["_id"],
}
},
)
if item_type == "comments":
objects.send_comment_notification(item_id)
if item_type == "groupEntries":
groups.send_entry_notification(item_id)
if item_type == "groupForumTopicReplies":
groups.send_forum_topic_reply_notification(item_id)
return {"success": True}

View File

@ -121,6 +121,11 @@ def update(user, username, data):
"$unset", {}
): # Also unset blurhash if removing avatar
updater["$unset"]["avatarBlurHash"] = ""
if "$set" in updater and (
"avatar" in data or "bio" in data or "website" in data or "username" in data
):
updater["$set"]["moderationRequired"] = True
util.send_moderation_request(user, "users", user)
db.users.update_one({"username": username}, updater)
return get(user, data.get("username", username))

View File

@ -758,6 +758,36 @@ def root_groups():
return util.jsonify(root.get_groups(util.get_user(required=True)))
@app.route("/root/moderation", methods=["GET"])
def root_moderation():
return util.jsonify(root.get_moderation(util.get_user(required=True)))
@app.route("/root/moderation/<item_type>/<id>", methods=["PUT", "DELETE"])
def root_moderation_item(item_type, id):
return util.jsonify(
root.moderate(
util.get_user(required=True), item_type, id, request.method == "PUT"
)
)
## REPORTS
@app.route("/reports", methods=["POST"])
@use_args(
{
"referrer": fields.Str(),
"url": fields.Str(required=True, validate=validate.Length(min=5)),
"description": fields.Str(required=True, validate=validate.Length(min=5)),
}
)
def reports(args):
util.send_report_email(args)
return {"success": True}
## ActivityPub Support

View File

@ -1,3 +1,4 @@
import os
import json
import datetime
from flask import request, Response
@ -7,7 +8,7 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from bson.objectid import ObjectId
from api import accounts
from util import util
from util import util, mail
errors = werkzeug.exceptions
@ -92,6 +93,34 @@ def build_updater(obj, allowed_keys):
return updater
def send_report_email(report):
if not report:
return
mail.send(
{
"to": os.environ.get("ADMIN_EMAIL"),
"subject": "{} report".format(os.environ.get("APP_NAME")),
"text": "A new report has been submitted: {0}".format(
json.dumps(report, indent=4)
),
}
)
def send_moderation_request(from_user, item_type, item):
if not from_user or not item_type or not item:
return
mail.send(
{
"to": os.environ.get("ADMIN_EMAIL"),
"subject": "{} moderation needed".format(os.environ.get("APP_NAME")),
"text": "New content has been added by {0} ({1}) and needs moderating: {2} ({3})".format(
from_user["username"], from_user["email"], item_type, item["_id"]
),
}
)
def generate_rsa_keypair():
private_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
private_pem = private_key.private_bytes(

View File

@ -9,3 +9,9 @@ VITE_IOS_APP_URL="https://apps.apple.com/gb/app/treadl/id1525094357"
VITE_ANDROID_APP_URL="https://play.google.com/store/apps/details/Treadl?id=com.treadl"
VITE_CONTACT_EMAIL="hello@treadl.com"
VITE_APP_NAME="Treadl"
VITE_APP_DOMAIN="treadl.com"
VITE_ONLINE_SAFETY_URL="https://git.wilw.dev/wilw/treadl/wiki/Online-Safety"
VITE_FOLLOWING_ENABLED="false"
VITE_GROUP_DISCOVERY_ENABLED="false"
VITE_USER_DISCOVERY_ENABLED="false"
VITE_GROUPS_ENABLED="false"

View File

@ -6,5 +6,11 @@ export const root = {
},
getGroups (success, fail) {
api.authenticatedRequest('GET', '/root/groups', null, success, fail)
},
getToBeModerated (success, fail) {
api.authenticatedRequest('GET', '/root/moderation', null, success, fail)
},
moderate (itemType, itemId, allowed, success, fail) {
api.authenticatedRequest(allowed ? 'PUT' : 'DELETE', `/root/moderation/${itemType}/${itemId}`, null, success, fail)
}
}

View File

@ -33,5 +33,8 @@ export const users = {
},
getFeed (username, success, fail) {
api.authenticatedRequest('GET', `/users/${username}/feed`, null, success, fail)
},
submitReport (data, success, fail) {
api.unauthenticatedRequest('POST', '/reports', data, success, fail)
}
}

View File

@ -8,6 +8,10 @@ import api from '../../api'
import utils from '../../utils/utils.js'
import FollowButton from './FollowButton'
const FOLLOWING_ENABLED = import.meta.env.VITE_FOLLOWING_ENABLED === 'true'
const GROUP_DISCOVERY_ENABLED = import.meta.env.VITE_GROUP_DISCOVERY_ENABLED === 'true'
const USER_DISCOVERY_ENABLED = import.meta.env.VITE_USER_DISCOVERY_ENABLED === 'true'
export default function ExploreCard ({ count, asCard }) {
const [highlightProjects, setHighlightProjects] = useState([])
const [highlightUsers, setHighlightUsers] = useState([])
@ -24,8 +28,8 @@ export default function ExploreCard ({ count, asCard }) {
setLoading(true)
api.search.discover(count || 3, ({ highlightProjects, highlightUsers, highlightGroups }) => {
setHighlightProjects(highlightProjects)
setHighlightUsers(highlightUsers)
setHighlightGroups(highlightGroups)
if (USER_DISCOVERY_ENABLED) { setHighlightUsers(highlightUsers) }
if (GROUP_DISCOVERY_ENABLED) { setHighlightGroups(highlightGroups) }
setLoading(false)
})
}, [userId])
@ -57,6 +61,8 @@ export default function ExploreCard ({ count, asCard }) {
</List>
</>}
{USER_DISCOVERY_ENABLED &&
<>
<h4>Find others on {utils.appName()}</h4>
{loading && <BulletList />}
{highlightUsers?.length > 0 &&
@ -68,7 +74,8 @@ export default function ExploreCard ({ count, asCard }) {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<UserChip user={u} className='umami--click--discover-user' />
<div>
<FollowButton compact targetUser={u} onChange={f => updateFollowing(u._id, f)} />
{FOLLOWING_ENABLED &&
<FollowButton compact targetUser={u} onChange={f => updateFollowing(u._id, f)} />}
</div>
</div>
</List.Content>
@ -76,7 +83,10 @@ export default function ExploreCard ({ count, asCard }) {
)}
</List>
</>}
</>}
{GROUP_DISCOVERY_ENABLED &&
<>
<h4>Discover a group</h4>
{loading && <BulletList />}
{highlightGroups?.length > 0 &&
@ -90,6 +100,7 @@ export default function ExploreCard ({ count, asCard }) {
</div>
)}
</>}
</>}
</div>
)
}

View File

@ -7,6 +7,8 @@ import api from '../../api'
import UserChip from './UserChip'
import DiscoverCard from './DiscoverCard'
const FOLLOWING_ENABLED = import.meta.env.VITE_FOLLOWING_ENABLED === 'true'
export default function Feed () {
const [feed, setFeed] = useState([])
const [loading, setLoading] = useState(false)
@ -17,7 +19,7 @@ export default function Feed () {
const username = user?.username
useEffect(() => {
if (!username) return
if (!username || !FOLLOWING_ENABLED) return
setLoading(true)
api.users.getFeed(username, result => {
setFeed(result.feed)
@ -28,6 +30,8 @@ export default function Feed () {
return (
<Card fluid>
<Card.Content style={{ maxHeight: 500, overflowY: 'scroll' }}>
{FOLLOWING_ENABLED &&
<>
<Card.Header style={{ marginBottom: 10 }}>Recent activity</Card.Header>
{loading &&
<div>
@ -36,7 +40,6 @@ export default function Feed () {
{!loading && !feed?.length &&
<div>
<p style={{ background: 'rgba(0,0,0,0.05)', padding: 5, borderRadius: 5 }}><Icon name='feed' /> Your feed is empty. You can <Link to='/explore'>follow others</Link> to stay up-to-date.</p>
<DiscoverCard />
</div>}
{!loading && feed?.map(item =>
<div key={item._id} style={{ display: 'flex', alignItems: 'center', marginBottom: 10 }}>
@ -63,6 +66,8 @@ export default function Feed () {
</div>
</div>
)}
</>}
<DiscoverCard />
</Card.Content>
</Card>
)

View File

@ -11,6 +11,7 @@ import api from '../../api'
import UserChip from './UserChip'
import NewFeedMessage from './NewFeedMessage'
import FormattedMessage from './FormattedMessage'
import ReportLink from './ReportLink'
const StyledMessage = styled.div`
padding: 6px;
@ -58,6 +59,8 @@ const FeedMessage = connect(
return (
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'end', justifyContent: 'space-between' }}>
<div>
<UserChip user={post.authorUser} meta={moment(post.createdAt).fromNow()} />
{canDelete() &&
<Dropdown icon='ellipsis horizontal' style={{ marginLeft: 10 }}>
@ -65,6 +68,11 @@ const FeedMessage = connect(
<Dropdown.Item icon='trash' content='Delete' onClick={() => deletePost(post._id)} />
</Dropdown.Menu>
</Dropdown>}
</div>
<div>
<ReportLink style={{ fontSize: 10 }} referrer={`/comments/${post._id}`} />
</div>
</div>
<div style={{ marginTop: 10 }}>
<StyledMessage>
@ -82,6 +90,7 @@ const FeedMessage = connect(
<Button key={a.url} as='a' href={a.url} target='_blank' rel='noopener noreferrer' size='tiny' icon='download' content={a.name} style={{ margin: 4 }} />
)}
</div>}
{!post.inReplyTo &&
<div style={{ padding: 10 }}>
{utils.hasGroupPermission(user, group, 'postNoticeboard') && replyingTo !== post._id && !post.inReplyTo && onReplyPosted &&

View File

@ -41,8 +41,13 @@ export default function Footer () {
</p>
<p>
<Icon name='file alternate outline' />
<Link to='terms-of-use'>Terms of Use</Link>
<Link to='/terms-of-use'>Terms of Use</Link>
</p>
{import.meta.env.VITE_ONLINE_SAFETY_URL &&
<p>
<Icon name='file alternate outline' />
<a href={import.meta.env.VITE_ONLINE_SAFETY_URL} target='_blank' rel='noopener noreferrer'>Online Safety</a>
</p>}
{import.meta.env.VITE_STATUS_URL &&
<p>
<a target='_blank' rel='noopener noreferrer' href={import.meta.env.VITE_STATUS_URL}>

View File

@ -12,6 +12,8 @@ import UserChip from './UserChip'
import SupporterBadge from './SupporterBadge'
import SearchBar from './SearchBar'
const GROUPS_ENABLED = import.meta.env.VITE_GROUPS_ENABLED === 'true'
const StyledNavBar = styled.div`
height:60px;
background: linen;
@ -61,8 +63,9 @@ export default function NavBar () {
<Link to={user ? '/projects' : '/'}><img alt={`${utils.appName()} logo`} src={logo} className='logo' /></Link>
<div style={{ flex: 1 }}>
<Menu secondary>
<Menu.Item className='above-mobile' as={Link} to='/projects' name='projects' active={location.pathname === '/projects'} />
<Menu.Item className='above-mobile' as={Link} to='/projects' name='my projects' active={location.pathname === '/projects'} />
<Menu.Item className='above-mobile' as={Link} to='/explore' name='explore' active={location.pathname === '/explore'} />
{GROUPS_ENABLED &&
<Menu.Item className='above-mobile' active={location.pathname.startsWith('/groups')} name='Groups'>
<Dropdown
pointing='top left' icon={null}
@ -77,7 +80,7 @@ export default function NavBar () {
<Dropdown.Item as={Link} to='/groups/new' icon='plus' content='Create a new group' />
</Dropdown.Menu>
</Dropdown>
</Menu.Item>
</Menu.Item>}
{user && !isSupporter && (import.meta.env.VITE_PATREON_URL || import.meta.env.VITE_KOFI_URL) &&
<Menu.Item className='above-mobile'>
<Popup

View File

@ -0,0 +1,29 @@
import React from 'react'
import styled from 'styled-components'
import { Link } from 'react-router-dom'
export const LinkContainer = styled.span`
display: inline-block;
border: 1px solid rgb(240,240,240);
border-radius: 5px;
padding: 5px;
${p => p.marginTop ? 'margin-top: 10px;' : ''}
${p => p.marginLeft ? 'margin-left: 10px;' : ''}
${p => p.marginBottom ? 'margin-bottom: 10px;' : ''}
color: #1e70bf;
.emoji{
margin-right: 5px;
}
`
function ReportLink ({ className, text, referrer, marginTop, marginLeft, marginBottom, style }) {
return (
<LinkContainer style={style} marginTop={marginTop} marginLeft={marginLeft} marginBottom={marginBottom}>
<Link to={`/report?referrer=${referrer}`} className={className}>
<span className='emoji'>📢</span>
{text || 'Report'}
</Link>
</LinkContainer>
)
}
export default ReportLink

View File

@ -1,6 +1,6 @@
import React, { useEffect } from 'react'
import styled from 'styled-components'
import { Popup, Loader, Grid, List, Input, Icon } from 'semantic-ui-react'
import { Popup, Loader, Grid, List, Input } from 'semantic-ui-react'
import { useDispatch, useSelector } from 'react-redux'
import { Link } from 'react-router-dom'
import { useDebouncedCallback } from 'use-debounce'
@ -10,6 +10,8 @@ import api from '../../api'
import UserChip from './UserChip'
const USER_DISCOVERY_ENABLED = import.meta.env.VITE_USER_DISCOVERY_ENABLED === 'true'
const StyledSearchBar = styled.div`
background-color:rgba(0,0,0,0.1);
padding:5px;
@ -106,7 +108,7 @@ export default function SearchBar () {
})}
</List>
</Grid.Column>}
{searchResults?.users?.length > 0 &&
{USER_DISCOVERY_ENABLED && searchResults?.users?.length > 0 &&
<Grid.Column width={6}>
<h4>Users</h4>
{searchResults?.users?.map(u =>
@ -115,7 +117,7 @@ export default function SearchBar () {
</Grid.Column>}
{(searchResults?.projects.length > 0 || searchResults.groups.length > 0) &&
<Grid.Column width={10}>
<h4>Projects & groups</h4>
<h4>Projects</h4>
<List>
{searchResults?.projects?.map(p =>
<List.Item key={p._id}>
@ -126,15 +128,6 @@ export default function SearchBar () {
</List.Content>
</List.Item>
)}
{searchResults?.groups?.map(g =>
<List.Item key={g._id}>
<List.Icon name='users' size='large' verticalAlign='middle' />
<List.Content>
<List.Header as={Link} to={`/groups/${g._id}`}>{g.name}</List.Header>
<List.Description><small>{g.closed ? <span><Icon name='lock' /> Closed group</span> : <span>Open group</span>}</small></List.Description>
</List.Content>
</List.Item>
)}
</List>
</Grid.Column>}
</Grid>)}

View File

@ -17,6 +17,8 @@ import PatternLoader from '../includes/PatternLoader'
import Tour from '../includes/Tour'
import Feed from '../includes/Feed'
const GROUPS_ENABLED = import.meta.env.VITE_GROUPS_ENABLED === 'true'
function Home () {
const [runJoyride, setRunJoyride] = useState(false)
const dispatch = useDispatch()
@ -90,6 +92,7 @@ function Home () {
<Feed />
{GROUPS_ENABLED &&
<Card fluid className='joyride-groups' style={{ opacity: 0.8 }}>
<Card.Content>
<Card.Header>Your groups</Card.Header>
@ -121,7 +124,7 @@ function Home () {
<Button className='joyride-createGroup' fluid size='small' icon='plus' content='Create a new group' as={Link} to='/groups/new' />
<HelpLink link='/docs/groups' text='Learn more about groups' marginTop />
</Card.Content>
</Card>
</Card>}
</Grid.Column>

View File

@ -0,0 +1,67 @@
import React, { useState } from 'react'
import { Helmet } from 'react-helmet'
import { Container, Message, Divider, Button, Form, FormField } from 'semantic-ui-react'
import { toast } from 'react-toastify'
import { useSearchParams } from 'react-router-dom'
import api from '../../api'
const APP_NAME = import.meta.env.VITE_APP_NAME
export default function Report () {
const [searchParams] = useSearchParams()
const referrerString = searchParams?.get('referrer') || ''
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const [referrer] = useState(referrerString)
const [url, setUrl] = useState(referrerString)
const [description, setDescription] = useState('')
const submit = () => {
setLoading(true)
api.users.submitReport({ referrer, url, description }, () => {
setLoading(false)
setSuccess(true)
}, (err) => {
setLoading(false)
toast.error(err.message)
})
}
return (
<Container style={{ marginTop: '40px' }}>
<Helmet title='Report Content' />
<h2>Make a report or complaint</h2>
<p>Online safety is hugely important for communities like {APP_NAME}. If you see something (e.g. user-generated content) that you consider to be potentially harmful or abusive to you or to others, please report it to us using the form below.</p>
<p>You can also use the form to submit a complaint about content, our duty of care, our handling of previous complaints, or anything else relevant to online safety or the operation of {APP_NAME}.</p>
<p>We review all reports and will take appropriate action as soon as possible.</p>
{import.meta.env.VITE_ONLINE_SAFETY_URL &&
<Message>Read more about <a href={import.meta.env.VITE_ONLINE_SAFETY_URL} target='_blank' rel='noopener noreferrer'>Online Safety on {import.meta.env.VITE_APP_NAME}</a>.</Message>}
<Divider />
{success
? (
<div>
<h3>Thank you for your report</h3>
<p>Your report has been submitted. We will review it as soon as possible.</p>
</div>)
: (
<Form>
<FormField>
<label>URL or description of the content you saw</label>
<p><small>If the information here is too vague, we may not be able to find what you are referring to. If we have auto-filled it for you, you can leave it as it is.</small></p>
<input placeholder='https://www...' value={url} onChange={e => setUrl(e.target.value)} />
</FormField>
<FormField>
<label>Please describe the nature of your report or complaint</label>
<p><small>For example: What is the issue? Why do you think the content is harmful or abusive? What is your complaint?</small></p>
<textarea placeholder='Please provide as much detail as possible.' value={description} onChange={e => setDescription(e.target.value)} />
</FormField>
<p>By submitting, you acknowledge that we have not asked for your contact details and that we therefore will not be able to provide any updates about this complaint to you.</p>
<Button loading={loading} color='teal' onClick={submit}>Submit</Button>
</Form>)}
</Container>
)
}

View File

@ -9,6 +9,7 @@ import utils from '../../../utils/utils'
import UserChip from '../../includes/UserChip'
import NewFeedMessage from '../../includes/NewFeedMessage'
import FormattedMessage from '../../includes/FormattedMessage'
import ReportLink from '../../includes/ReportLink'
import StartChatImage from '../../../images/startchat.png'
export default function ForumTopic () {
@ -101,10 +102,11 @@ export default function ForumTopic () {
<Segment key={reply._id}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<UserChip user={reply.author} compact />
<div style={{ fontSize: 12, color: 'rgb(150,150,150)', display: 'flex' }}>
<div style={{ fontSize: 12, color: 'rgb(150,150,150)', display: 'flex', alignItems: 'center' }}>
<div>{new Date(reply.createdAt).toLocaleString()}</div>
{(utils.isGroupAdmin(user, group) || user._id === reply.author._id) &&
<div role='button' style={{ textDecoration: 'underline', cursor: 'pointer', marginLeft: 10 }} onClick={() => handleDeleteReply(reply._id)}>Delete</div>}
<ReportLink referrer={`/topicReplies/${reply._id}`} style={{ fontSize: 11 }} marginLeft='10px' />
</div>
</div>
<FormattedMessage content={reply.content} />

View File

@ -10,6 +10,7 @@ import api from '../../../api'
import UserChip from '../../includes/UserChip'
import HelpLink from '../../includes/HelpLink'
import ReportLink from '../../includes/ReportLink'
function Group () {
const { id } = useParams()
@ -131,6 +132,7 @@ function Group () {
</Card>
<HelpLink link='/docs/groups#a-tour-around-your-new-group' />
<ReportLink referrer={`/groups/${group._id}`} text='Report this group' marginTop='5px' />
</Grid.Column>
<Grid.Column computer={12}>

View File

@ -18,6 +18,8 @@ const PERMISSIONS = [
{ name: 'postForumTopicReplies', label: 'Allow members to reply to forum topics' }
]
const GROUP_DISCOVERY_ENABLED = import.meta.env.VITE_GROUP_DISCOVERY_ENABLED === 'true'
function Settings () {
const navigate = useNavigate()
const dispatch = useDispatch()
@ -87,7 +89,8 @@ function Settings () {
<div style={{ marginLeft: 63, color: 'rgb(150,150,150)', marginBottom: 20 }}>
<p>In a closed group, new members must be invited or approved to join.</p>
</div>
<Form.Checkbox toggle checked={group.advertised} label='Publicise this group' onChange={(e, c) => dispatch(actions.groups.updateGroup(group._id, { advertised: c.checked }))} />
{GROUP_DISCOVERY_ENABLED &&
<Form.Checkbox toggle checked={group.advertised} label='Publicise this group' onChange={(e, c) => dispatch(actions.groups.updateGroup(group._id, { advertised: c.checked }))} />}
<div style={{ marginLeft: 63, color: 'rgb(150,150,150)' }}>
<p>If a group is publicised, it may allow other people to discover the group.</p>
</div>

View File

@ -14,6 +14,7 @@ import RichTextViewer from '../../includes/RichTextViewer'
import DraftPreview from './objects/DraftPreview'
import NewFeedMessage from '../../includes/NewFeedMessage'
import FeedMessage from '../../includes/FeedMessage'
import ReportLink from '../../includes/ReportLink'
function ObjectViewer () {
const [editingName, setEditingName] = useState(false)
@ -96,7 +97,8 @@ function ObjectViewer () {
<>
<Helmet title={`${object.name || 'Project Item'} | ${project?.name || 'Project'}`} />
<div style={{ display: 'flex', justifyContent: 'end' }}>
<div style={{ display: 'flex', justifyContent: 'end', alignItems: 'center' }}>
{object.type === 'pattern' && (utils.canEditProject(user, project) || project.openSource || object.previewUrl) &&
<>
<Dropdown direction='left' icon={null} trigger={<Button size='tiny' secondary icon='download' content='Download pattern' loading={downloading} disabled={downloading} />}>
@ -138,6 +140,9 @@ function ObjectViewer () {
</Dropdown>
</>}
{object.type === 'file' &&
<ReportLink referrer={`/objects/${object._id}`} marginLeft='5px' />}
</div>
{editingName

View File

@ -9,6 +9,7 @@ import api from '../../../api'
import UserChip from '../../includes/UserChip'
import HelpLink from '../../includes/HelpLink'
import ReportLink from '../../includes/ReportLink'
import ObjectCreator from '../../includes/ObjectCreator'
import FormattedMessage from '../../includes/FormattedMessage'
@ -139,6 +140,8 @@ function Project () {
</Card>}
<HelpLink link='/docs/projects' />
{!utils.canEditProject(user, project) &&
<ReportLink referrer={`/projects/${project._id}`} text='Report this project' marginTop='5px' />}
</Grid.Column>
)}

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
import { Table, Container } from 'semantic-ui-react'
import { Table, Container, Button } from 'semantic-ui-react'
import { Link } from 'react-router-dom'
import { useSelector } from 'react-redux'
import moment from 'moment'
@ -7,6 +7,7 @@ import api from '../../../api'
function Root () {
const [users, setUsers] = useState([])
const [toModerate, setToModerate] = useState([])
const { user } = useSelector(state => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0]
@ -18,6 +19,16 @@ function Root () {
api.root.getUsers(({ users }) => {
setUsers(users)
})
api.root.getToBeModerated(({ objects, comments, users, groups, groupEntries, groupForumTopicReplies }) => {
const newToModerate = []
objects.forEach(o => newToModerate.push({ ...o, itemType: 'objects' }))
comments.forEach(c => newToModerate.push({ ...c, itemType: 'comments' }))
users.forEach(u => newToModerate.push({ ...u, itemType: 'users' }))
groups.forEach(g => newToModerate.push({ ...g, itemType: 'groups' }))
groupEntries.forEach(e => newToModerate.push({ ...e, itemType: 'groupEntries' }))
groupForumTopicReplies.forEach(r => newToModerate.push({ ...r, itemType: 'groupForumTopicReplies' }))
setToModerate(newToModerate)
})
}, [user])
const testSentry = () => {
@ -25,9 +36,75 @@ function Root () {
console.log(x.y.z)
}
const moderate = (itemType, itemId, allowed) => {
api.root.moderate(itemType, itemId, allowed, () => {
setToModerate(toModerate.filter(o => o._id !== itemId))
})
}
return (
<Container style={{ marginTop: '40px' }}>
<button onClick={testSentry}>Test Sentry</button>
<h2>Moderation</h2>
{toModerate?.length > 0
? (
<Table compact basic>
<Table.Header>
<Table.Row>
<Table.Cell>Type</Table.Cell>
<Table.Cell>Item</Table.Cell>
<Table.Cell>Action</Table.Cell>
</Table.Row>
</Table.Header>
<Table.Body>
{toModerate.map(o =>
<Table.Row key={o._id}>
<Table.Cell>{o.itemType}</Table.Cell>
<Table.Cell>
{o.itemType === 'comments' &&
<span>{o.content}</span>}
{o.itemType === 'objects' &&
<span>
<Link to={o.url} target='_blank' rel='noopener noreferrer'>{o.name}</Link>
{o.isImage &&
<img src={o.url} style={{ width: '100px' }} />}
</span>}
{o.itemType === 'users' &&
<span><Link to={`/${o.username}`}>{o.username}</Link></span>}
{o.itemType === 'groups' &&
<span><Link to={`/groups/${o._id}`}>{o.name}</Link></span>}
{o.itemType === 'groupEntries' &&
<span>
{o.content}
{o.attachments?.map(a =>
<span key={a._id}>
<a href={a.url} target='_blank' rel='noopener noreferrer'>{a.name}</a>
</span>
)}
</span>}
{o.itemType === 'groupForumTopicReplies' &&
<span>
{o.content}
{o.attachments?.map(a =>
<span key={a._id}>
<a href={a.url} target='_blank' rel='noopener noreferrer'>{a.name}</a>
</span>
)}
</span>}
</Table.Cell>
<Table.Cell>
<Button onClick={() => moderate(o.itemType, o._id, true)}>Allow</Button>
<Button onClick={() => moderate(o.itemType, o._id, false)}>Deny</Button>
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table>)
: (
<p>Nothing to moderate</p>
)}
<h2>Users</h2>
<Table compact basic>
<Table.Header>

View File

@ -12,6 +12,9 @@ import api from '../../../api'
import BlurrableImage from '../../includes/BlurrableImage'
import SupporterBadge from '../../includes/SupporterBadge'
import FollowButton from '../../includes/FollowButton'
import ReportLink from '../../includes/ReportLink'
const FOLLOWING_ENABLED = import.meta.env.VITE_FOLLOWING_ENABLED === 'true'
function Profile () {
const [loading, setLoading] = useState(false)
@ -84,11 +87,11 @@ function Profile () {
{profileUser.isSilverSupporter && !profileUser.isGoldSupporter &&
<div style={{ marginTop: 10 }}><SupporterBadge type='silver' /></div>}
</Card.Content>
{profileUser._id !== user?._id &&
{FOLLOWING_ENABLED && profileUser._id !== user?._id &&
<Card.Content style={{ marginTop: 10 }}>
<FollowButton targetUser={profileUser} />
</Card.Content>}
{profileUser._id === user?._id &&
{FOLLOWING_ENABLED && profileUser._id === user?._id &&
<Card.Content extra textAlign='right'>
<p><Icon name='users' /> You have {user?.followerCount || 0} followers</p>
</Card.Content>}
@ -108,6 +111,8 @@ function Profile () {
)}
</Card>
<ReportLink referrer={`/users/${profileUser._id}`} text='Report this user' />
{(profileUser.bio || profileUser.website || profileUser.twitter || profileUser.facebook) &&
<Card fluid>
<Card.Content>

View File

@ -13,6 +13,12 @@ function TermsOfUse () {
<p>This policy is designed to be accessible, understandable, and easy to read without legal and other jargon. If you have any comments, questions, or concerns about this policy, please get in touch with us by emailing {EMAIL}.</p>
<p>This document will have slight changes made to it occasionally. Please refer back to it from time to time.</p>
{import.meta.env.VITE_ONLINE_SAFETY_URL &&
<div>
<h2>Online Safety</h2>
<p>For information on {APP_NAME}'s online safety processes and notice, please see our <a href={import.meta.env.VITE_ONLINE_SAFETY_URL}>Online Safety</a> page.</p>
</div>}
<h2>Terms</h2>
<p>{APP_NAME} does not guarantee constant availability of service access and accepts no liability for downtime or access failure due to circumstances beyond its reasonable control (including any failure by ISP or system provider).</p>
@ -26,7 +32,7 @@ function TermsOfUse () {
<p>Our services are provided on an as is, as available basis without warranties of any kind, express or implied, including, but not limited to, those of TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE or NON-INFRINGEMENT or any warranty arising from a course of dealing, usage, or trade practice. No advice or written information provided shall create a warranty; nor shall members or visitors to our services rely on any such information or advice.</p>
<p>We reserve the right to permanently ban any user from our services for any reason related to mis-behaviour. We will be the sole judge of behavior and we do not offer appeals or refunds in those cases.</p>
<p>We reserve the right to permanently ban any user from our services for any reason related to mis-behaviour. We will be the sole judge of behavior and we do not offer appeals in those cases. We also reserve the right to remove any content at any time and for any reason, if we assess it to be misaligned with the service's goals (i.e. not weaving or art related), harmful, abusive, or illegal.</p>
<p>All users must follow the {APP_NAME} code of conduct:</p>

View File

@ -16,7 +16,8 @@ import Home from './components/main/Home'
import MarketingHome from './components/marketing/Home'
import PrivacyPolicy from './components/marketing/PrivacyPolicy'
import TermsOfUse from './components//marketing/TermsOfUse'
import TermsOfUse from './components/marketing/TermsOfUse'
import Report from './components/main/Report'
import ForgottenPassword from './components/ForgottenPassword'
import ResetPassword from './components/ResetPassword'
@ -80,6 +81,7 @@ const router = createBrowserRouter([
{ path: 'explore', element: <Explore /> },
{ path: 'privacy', element: <PrivacyPolicy /> },
{ path: 'terms-of-use', element: <TermsOfUse /> },
{ path: 'report', element: <Report /> },
{ path: 'password/forgotten', element: <ForgottenPassword /> },
{ path: 'password/reset', element: <ResetPassword /> },
{

View File

@ -35,7 +35,7 @@ const utils = {
return group?.admins?.indexOf(user?._id) > -1 || user?.roles?.indexOf('root') > -1
},
hasGroupPermission (user, group, permission) {
return utils.isInGroup(user, group._id) && (utils.isGroupAdmin(user, group) || group?.memberPermissions?.indexOf(permission) > -1)
return utils.isInGroup(user, group?._id) && (utils.isGroupAdmin(user, group) || group?.memberPermissions?.indexOf(permission) > -1)
},
ensureHttp (s) {
if (s && s.toLowerCase().indexOf('http') === -1) return `http://${s}`