Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prevent banned users from creating new posts and comments #216

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions packages/frontpage/app/(app)/_components/post-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { revalidatePath } from "next/cache";
import { ReportDialogDropdownButton } from "./report-dialog";
import { DeleteButton } from "./delete-button";
import { ShareDropdownButton } from "./share-button";
import { isBanned } from "@/lib/data/db/user";

type PostProps = {
id: number;
Expand Down Expand Up @@ -54,7 +55,10 @@ export async function PostCard({
<VoteButton
voteAction={async () => {
"use server";
await ensureUser();
const user = await ensureUser();
if (await isBanned(user.did)) {
throw new Error("Author is banned");
}
await createVote({
subjectAuthorDid: author,
subjectCid: cid,
Expand All @@ -64,7 +68,10 @@ export async function PostCard({
}}
unvoteAction={async () => {
"use server";
await ensureUser();
const user = await ensureUser();
if (await isBanned(user.did)) {
throw new Error("Author is banned");
}
const vote = await getVoteForPost(id);
if (!vote) {
// TODO: Show error notification
Expand Down
40 changes: 12 additions & 28 deletions packages/frontpage/app/(app)/about/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import {
Heading1,
Paragraph,
Heading2,
TextLink,
} from "@/lib/components/ui/typography";
import { Metadata } from "next";
import { ReactNode } from "react";

Check failure on line 8 in packages/frontpage/app/(app)/about/page.tsx

View workflow job for this annotation

GitHub Actions / lint

'ReactNode' is defined but never used. Allowed unused vars must match /^_/u

export const metadata: Metadata = {
title: "About Frontpage",
Expand All @@ -18,55 +24,33 @@
Frontpage is a decentralised and federated link aggregator that&apos;s
built on the same protocol as Bluesky.
</Paragraph>

<Heading2>Community Guidelines</Heading2>

<Paragraph>
We want Frontpage to be a safe and welcoming place for everyone. And so
we ask that you follow these guidelines:
</Paragraph>

<ol className="my-6 ml-6 list-decimal [&>li]:mt-2">
<li>
Don&apos;t post hate speech, harassment, or other forms of abuse.
</li>
<li>Don&apos;t post content that is illegal or harmful.</li>
<li>Don&apos;t post adult content*.</li>
</ol>

<small className="text-sm font-medium leading-none">
* this is a temporary guideline while we build labeling and content
warning features.
</small>

<Paragraph>
Frontpage is moderated by it&apos;s core developers, but we also rely on
reports from users to help us keep the community safe. Please report any
content that violates our guidelines.
</Paragraph>
<Heading2 id="contact">Contact</Heading2>
<Paragraph>
Email us at{" "}
<TextLink href="mailto:[email protected]">[email protected]</TextLink>
.
</Paragraph>
</>
);
}

function Heading1({ children }: { children: ReactNode }) {
return (
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
{children}
</h1>
);
}

function Heading2({ children, id }: { children: ReactNode; id?: string }) {
return (
<h2
id={id}
className="mt-10 scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0"
>
{children}
</h2>
);
}

function Paragraph({ children }: { children: ReactNode }) {
return <p className="leading-7 [&:not(:first-child)]:mt-6">{children}</p>;
}
18 changes: 9 additions & 9 deletions packages/frontpage/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { FRONTPAGE_ATPROTO_HANDLE } from "@/lib/constants";
import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";
import { NotificationIndicator } from "./_components/notification-indicator";
import { TextLink } from "@/lib/components/ui/typography";

export default async function Layout({
children,
Expand Down Expand Up @@ -55,12 +56,11 @@ export default async function Layout({
<footer className="flex justify-between items-center text-gray-500 dark:text-gray-400">
<p>
Made by{" "}
<a
<TextLink
href={`https://bsky.app/profile/${FRONTPAGE_ATPROTO_HANDLE}`}
className="font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
>
@frontpage.fyi <OpenInNewWindowIcon className="inline" />
</a>
</TextLink>
</p>
</footer>
</div>
Expand Down Expand Up @@ -98,12 +98,12 @@ async function LoginOrLogout() {
<Link href={`/profile/${handle}`} className="cursor-pointer">
Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/about" className="cursor-pointer">
About
</Link>
</DropdownMenuItem>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/about" className="cursor-pointer">
About
</Link>
</DropdownMenuItem>
<Suspense fallback={null}>
{isAdmin().then((isAdmin) =>
isAdmin ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,25 @@ import { createReport } from "@/lib/data/db/report";
import { getVoteForComment } from "@/lib/data/db/vote";
import { ensureUser } from "@/lib/data/user";
import { revalidatePath } from "next/cache";
import { isBanned } from "@/lib/data/db/user";
import { TextLink } from "@/lib/components/ui/typography";

export async function createCommentAction(
input: { parentRkey?: string; postRkey: string; postAuthorDid: DID },
_prevState: unknown,
formData: FormData,
) {
const content = formData.get("comment") as string;
const user = await ensureUser();
if (await isBanned(user.did)) {
return {
error: (
<>
Your account is currently banned from creating new comments.{" "}
<TextLink href="/about#contact">Contact us</TextLink> to appeal.
</>
),
};
}

const [post, comment] = await Promise.all([
getPost(input.postAuthorDid, input.postRkey),
Expand All @@ -34,7 +45,7 @@ export async function createCommentAction(
]);

if (!post) {
throw new Error("Post not found");
return { error: "Failed to create comment. Post not found." };
}

if (post.status !== "live") {
Expand Down Expand Up @@ -99,7 +110,10 @@ export async function commentVoteAction(input: {
rkey: string;
authorDid: DID;
}) {
await ensureUser();
const user = await ensureUser();
if (await isBanned(user.did)) {
throw new Error("Author is banned");
}
await createVote({
subjectAuthorDid: input.authorDid,
subjectCid: input.cid,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,7 @@ import {
reportCommentAction,
} from "./actions";
import { ChatBubbleIcon, TrashIcon } from "@radix-ui/react-icons";
import {
useActionState,
useRef,
useState,
useId,
startTransition,
} from "react";
import { useRef, useState, useId, startTransition, useTransition } from "react";
import {
VoteButton,
VoteButtonState,
Expand Down Expand Up @@ -238,22 +232,32 @@ export function NewComment({
textAreaRef?: React.RefObject<HTMLTextAreaElement>;
}) {
const [input, setInput] = useState("");
const [_, action, isPending] = useActionState(
createCommentAction.bind(null, { parentRkey, postRkey, postAuthorDid }),
undefined,
);
const action = createCommentAction.bind(null, {
parentRkey,
postRkey,
postAuthorDid,
});
const [isPending, startTransition] = useTransition();
const id = useId();
const { toast } = useToast();
const textAreaId = `${id}-comment`;

return (
<form
action={action}
onSubmit={(event) => {
event.preventDefault();
startTransition(() => {
action(new FormData(event.currentTarget));
startTransition(async () => {
const result = await action(new FormData(event.currentTarget));
onActionDone?.();
setInput("");
if (result?.error) {
toast({
title: "Failed to create comment",
description: result.error,
type: "foreground",
});
} else {
setInput("");
}
});
}}
aria-busy={isPending}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"use server";

import { TextLink } from "@/lib/components/ui/typography";
import { DID } from "@/lib/data/atproto/did";
import { getVerifiedHandle } from "@/lib/data/atproto/identity";
import { createPost } from "@/lib/data/atproto/post";
import { uncached_doesPostExist } from "@/lib/data/db/post";
import { isBanned } from "@/lib/data/db/user";
import { DataLayerError } from "@/lib/data/error";
import { ensureUser } from "@/lib/data/user";
import { redirect } from "next/navigation";
Expand All @@ -26,6 +28,17 @@ export async function newPostAction(_prevState: unknown, formData: FormData) {
return { error: "Invalid URL" };
}

if (await isBanned(user.did)) {
return {
error: (
<>
Your account is currently banned from creating new posts.{" "}
<TextLink href="/about#contact">Contact us</TextLink> to appeal.
</>
),
};
}

try {
const { rkey } = await createPost({ title, url });
const [handle] = await Promise.all([
Expand Down
3 changes: 2 additions & 1 deletion packages/frontpage/app/(app)/profile/[user]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ReportDialogDropdownButton } from "../../_components/report-dialog";
import { reportUserAction } from "@/lib/components/user-hover-card";
import { Metadata } from "next";
import { LinkAlternateAtUri } from "@/lib/components/link-alternate-at";
import { isBanned } from "@/lib/data/db/user";

type Params = {
user: string;
Expand All @@ -33,7 +34,7 @@ export async function generateMetadata(props: {
}): Promise<Metadata> {
const params = await props.params;
const did = await getDidFromHandleOrDid(params.user);
if (!did) {
if (!did || (await isBanned(did))) {
notFound();
}
const [handle, profile] = await Promise.all([
Expand Down
5 changes: 5 additions & 0 deletions packages/frontpage/app/api/receive_hook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
unauthed_createCommentVote,
} from "@/lib/data/db/vote";
import { unauthed_createNotification } from "@/lib/data/db/notification";
import { isBanned } from "@/lib/data/db/user";

export async function POST(request: Request) {
const auth = request.headers.get("Authorization");
Expand All @@ -31,6 +32,10 @@ export async function POST(request: Request) {
}

const { ops, repo, seq } = commit.data;
if (await isBanned(repo)) {
throw new Error("[naughty] User is banned");
}

const service = await getPdsUrl(repo);
if (!service) {
throw new Error("No AtprotoPersonalDataServer service found");
Expand Down
48 changes: 48 additions & 0 deletions packages/frontpage/lib/components/ui/typography.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Link, { LinkProps } from "next/link";
import { ReactNode } from "react";

export function Heading1({ children }: { children: ReactNode }) {
return (
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
{children}
</h1>
);
}

export function Heading2({
children,
id,
}: {
children: ReactNode;
id?: string;
}) {
return (
<h2
id={id}
className="mt-10 scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0"
>
{children}
</h2>
);
}

export function Paragraph({ children }: { children: ReactNode }) {
return <p className="leading-7 [&:not(:first-child)]:mt-6">{children}</p>;
}

export function TextLink({
href,
children,
}: {
href: LinkProps["href"];
children: ReactNode;
}) {
return (
<Link
href={href}
className="font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
>
{children}
</Link>
);
}
13 changes: 13 additions & 0 deletions packages/frontpage/lib/data/db/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { db } from "@/lib/db";
import { DID } from "../atproto/did";
import * as schema from "@/lib/schema";
import { isAdmin } from "../user";
import { cache } from "react";
import { and, eq } from "drizzle-orm";

type ModerateUserInput = {
userDid: DID;
Expand Down Expand Up @@ -35,3 +37,14 @@ export async function moderateUser({
set: { isHidden: hide, labels: label, updatedAt: new Date() },
});
}

export const isBanned = cache(async (did: DID) => {
const bannedUser = await db.query.LabelledProfile.findFirst({
where: and(
eq(schema.LabelledProfile.did, did),
eq(schema.LabelledProfile.isHidden, true),
),
});

return Boolean(bannedUser);
});
Loading