diff --git a/docs/overview.md b/docs/overview.md index b2355fb..39550a1 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -13,34 +13,42 @@ * Has a logo and description of the product * Has a login button * If logged in - * Shows a list of people who have followed you, with a toggle to choose which are close friends - * Has a button to export the list of people as a string you can use in your email client + * Shows a list of updates. An update can be someone following you, someone asking to join one of your groups, or you being accepted into a group + * Shows a button to view your followers, with the first 5 shown as a preview + * Shows a button to view your groups, with the first 5 shown as a preview +* Followers screen + * Shows a list of people who have followed you, with a toggle next to each one to choose whether than person is a close friend + * Has a button to manually add new followers by email address * Shows a list of groups you are in * Has a button to create a new group -* Profile - * URL is slowpost.org/username - * Shows a person’s name, photo, and blurb - * Lists public groups that they are part of, and private groups you are both in - * Has a button to follow their posts - * If it’s your profile, can edit your name, photo, blurb +* Profile + * URL is slowpost.org/username + * Shows a person’s name, photo, and blurb + * Lists public groups that they are part of, and private groups you are both in + * Has a button to follow their posts + * If it’s your profile, can edit your name, photo, blurb + * Uploading a new profile photo supports common image formats up to 5MB and updates the photo immediately on your profile page * Login * URL is slowpast.org/p/login * Asks for the user’s email address * Then asks for the PIN it emailed them so the user can log in + * When running on localhost, allows the user to click "skip pin" to log in without having a PIN + * On the server, only allows "skip pin" if run with an env var telling it this is allowed * Asks you for your user name slug and real full name the first time you sign in + * If you try to sign in with an email that hasn't been signed in before, it asks you if you want to sign in + * If you try to sign up with an email that /has/ been signed in before, it asks you if you want to sign up * Group - * URL is slowpost.org/g/\[groupKey\] - * Has a non-guessable URL that is a key - * Shows a list of people in that group + * URL is slowpost.org/g/\[groupname\] + * Shows a list of people in that group + * Shows the name (and link to profile) of the group admin + * Each person's group bio shows a short summary (editable by that person) of their role in the group. * Clicking on a person takes you to their profile * Also has a button to ask to join the group * If you are a group admin, shows people who have asked to join, with a button to decide to admit them or not -* Followers - * Shows a list of people who have asked to follow you - * You can click on them to see their profile and ask to follow them - * You can choose which of them to give the “close friend” post -* Notifications - * Shows a list of updates +* Join Group + * Shown if you click the "join group" button on a group + * Asks you to enter a bio line to appear next to your profile on the group + * Lets you provide an optional message to the group admin ## Implementation @@ -60,10 +68,31 @@ I have the domain with cloudflare ## Database Model -* A "profile" collection contains one document for each person +* A "profile" collection contains one document for each person, with their username, real name, and bio * A "group" collection contains one document for each group * A "follow" collection contains one document for each follow relationship, with a key saying whether it is a "close" follow, and indexes to look up follow relationships in both directions -* A "member" collection contains one document for each group membership +* A "member" collection contains one document for each group membership, with their username, name, and per-group bio * A "notifications" collection contains one document for each notification sent to a user. Each of these is also sent my email. +* An "auth" collection has one document for each user. This contains their email, active login PIN, and any active login sessions (with their expirey times, and secure tokens) * Also have whatever collections you might need to manage login + +## Database Adapter + +* Regular server API functions don't call MongoDB directly. Instead they talk to it through a simple adapter layer. +* This adapter has the following methods: + * getDocument(collection, key) - Gets a single object. useful for getting profile, group, "user", etc objects that there is one of + * addDocument(collection, newKey, data) - Create a document + * updateDocument(collection, key, update) - Updates a document + * getChildLinks(collection, parentKey) | getParentLinks(collection, childKey) - Some collections represent one thing being part of something else. E.g. a group has members, a person has followers. In that case, we can query either all the children of a parent (e.g. the followers of a person) or the parents of a child (e.g. the people someone follows) + * addLink(collection, parentKey, childKey, data) - Create a new link + + +## Login model + +* A cookie contains username + real name + auth token + session expirey time +* An API allows you to request that it send an email (using PostMark) with a random PIN to the user's email address +* A "login" API takes an email and a valid PIN and generates a new login session, returns it as a value and sets it as a cookie +* If SKIP_PIN is set as an env variable (true when running local dev) then +* If the user is logged in, their name appears in the to right on the top-bar. Otherwise it says "Log In | Sign Up". + diff --git a/packages/client/components/ProfilePhotoUploader.tsx b/packages/client/components/ProfilePhotoUploader.tsx new file mode 100644 index 0000000..cf0b6e2 --- /dev/null +++ b/packages/client/components/ProfilePhotoUploader.tsx @@ -0,0 +1,137 @@ +import { ChangeEvent, useEffect, useId, useRef, useState } from 'react'; +import { Avatar, Button, Text, VertBox } from '../style'; + +const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB + +type UploadStatus = 'idle' | 'uploading' | 'success' | 'error'; + +export interface ProfilePhotoUploaderProps { + username: string; + name: string; + initialPhotoUrl: string; + onPhotoUpdated?: (photoUrl: string) => void; +} + +async function readFileAsDataUrl(file: File): Promise { + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === 'string') { + resolve(reader.result); + } else { + reject(new Error('Could not read the selected file.')); + } + }; + reader.onerror = () => { + reject(new Error('Could not read the selected file.')); + }; + reader.readAsDataURL(file); + }); +} + +export function ProfilePhotoUploader({ + username, + name, + initialPhotoUrl, + onPhotoUpdated +}: ProfilePhotoUploaderProps) { + const [photoUrl, setPhotoUrl] = useState(initialPhotoUrl); + const [status, setStatus] = useState('idle'); + const [message, setMessage] = useState(null); + const fileInputId = useId(); + const fileInputRef = useRef(null); + + useEffect(() => { + setPhotoUrl(initialPhotoUrl); + }, [initialPhotoUrl]); + + const resetInput = () => { + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleSelectFile = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + + if (!file.type.startsWith('image/')) { + setStatus('error'); + setMessage('Please choose an image file.'); + resetInput(); + return; + } + + if (file.size > MAX_FILE_SIZE_BYTES) { + setStatus('error'); + setMessage('Please choose an image smaller than 5MB.'); + resetInput(); + return; + } + + setStatus('uploading'); + setMessage('Uploading photo…'); + + try { + const photoData = await readFileAsDataUrl(file); + const response = await fetch(`/api/profile/${username}/photo`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ photoData }) + }); + if (!response.ok) { + let errorMessage = 'Unable to upload your photo. Please try again.'; + try { + const errorJson = (await response.json()) as { message?: string }; + if (errorJson.message) { + errorMessage = errorJson.message; + } + } catch (parseError) { + console.warn('Unable to parse photo upload error response', parseError); + } + throw new Error(errorMessage); + } + const data = (await response.json()) as { photoUrl: string }; + setPhotoUrl(data.photoUrl); + onPhotoUpdated?.(data.photoUrl); + setStatus('success'); + setMessage('Profile photo updated.'); + } catch (error) { + console.error('Failed to upload profile photo', error); + setStatus('error'); + setMessage(error instanceof Error ? error.message : 'Unable to upload your photo.'); + } finally { + resetInput(); + } + }; + + return ( + + + + + {message ? ( + + {message} + + ) : null} + + ); +} + +export default ProfilePhotoUploader; diff --git a/packages/client/components/ProfileSummary.tsx b/packages/client/components/ProfileSummary.tsx index c9404f3..d6f5ec7 100644 --- a/packages/client/components/ProfileSummary.tsx +++ b/packages/client/components/ProfileSummary.tsx @@ -1,18 +1,35 @@ import Link from 'next/link'; +import { useEffect, useState } from 'react'; import type { Profile } from '../lib/data'; import { Avatar, Button, Card, HorizBox, Pad, PadBox, Text, TileGrid, VertBox } from '../style'; +import { ProfilePhotoUploader } from './ProfilePhotoUploader'; export type ProfileSummaryProps = { profile: Profile; }; export function ProfileSummary({ profile }: ProfileSummaryProps) { + const [photoUrl, setPhotoUrl] = useState(profile.photoUrl); + + useEffect(() => { + setPhotoUrl(profile.photoUrl); + }, [profile.photoUrl]); + return ( - + {profile.isSelf ? ( + + ) : ( + + )}

{profile.name} diff --git a/packages/client/components/__tests__/ProfileSummary.stories.tsx b/packages/client/components/__tests__/ProfileSummary.stories.tsx index c67e4d4..83f0a18 100644 --- a/packages/client/components/__tests__/ProfileSummary.stories.tsx +++ b/packages/client/components/__tests__/ProfileSummary.stories.tsx @@ -19,6 +19,7 @@ type Story = StoryObj; export const OwnProfile: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); + await expect(canvas.getByRole('button', { name: /change photo/i })).toBeInTheDocument(); await expect(canvas.getByRole('button', { name: /edit profile/i })).toBeInTheDocument(); } }; @@ -30,5 +31,6 @@ export const OtherProfile: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByRole('button', { name: /follow/i })).toBeInTheDocument(); + expect(canvas.queryByRole('button', { name: /change photo/i })).not.toBeInTheDocument(); } }; diff --git a/packages/client/vitest.setup.ts b/packages/client/vitest.setup.ts index 9531ff8..283bcbb 100644 --- a/packages/client/vitest.setup.ts +++ b/packages/client/vitest.setup.ts @@ -78,6 +78,17 @@ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => ); } + if (url.includes('/api/profile/') && url.endsWith('/photo') && method === 'POST') { + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : {}; + return new Response( + JSON.stringify({ photoUrl: typeof body?.photoData === 'string' ? body.photoData : '' }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ); + } + return new Response('Not Found', { status: 404 }); }); diff --git a/packages/data/src/store.ts b/packages/data/src/store.ts index 92a54ec..5246c85 100644 --- a/packages/data/src/store.ts +++ b/packages/data/src/store.ts @@ -274,6 +274,16 @@ class SlowpostStoreImpl implements SlowpostStore { return { username: profile.username, pendingFollowers }; } + async updateProfilePhoto(username: string, photoUrl: string): Promise { + const sanitizedUrl = photoUrl.trim(); + if (!sanitizedUrl) { + throw new Error('Photo URL is required'); + } + const profile = await this.requireProfile(username); + await this.collections.profiles.updateOne({ username: profile.username }, { $set: { photoUrl: sanitizedUrl } }); + return { ...profile, photoUrl: sanitizedUrl }; + } + async createLoginSession(email: string, intent: 'login' | 'signup'): Promise { const normalizedEmail = this.normalizeEmail(email); const pin = randomBytes(3).toString('hex'); diff --git a/packages/data/src/types.ts b/packages/data/src/types.ts index 7825456..261b27b 100644 --- a/packages/data/src/types.ts +++ b/packages/data/src/types.ts @@ -97,6 +97,7 @@ export interface SlowpostStore { requestGroupJoin(username: string, groupKey: string): Promise<{ requestId: string }>; requestFollow(follower: string, following: string): Promise; getFollowersView(username: string): Promise; + updateProfilePhoto(username: string, photoUrl: string): Promise; createLoginSession(email: string, intent: 'login' | 'signup'): Promise; verifyLogin(email: string, pin: string): Promise; forceVerifyLogin(email: string, intent?: 'login' | 'signup'): Promise; diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 0fafee3..93e8067 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -23,103 +23,6 @@ const loginCookieOptions: CookieOptions = { type SessionSnapshot = Pick; const loginTokens = new Map(); -const localHostnames = new Set(['localhost', '127.0.0.1', '::1']); -const localIpAddresses = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']); - -function normalizeHost(value: string): string | undefined { - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - - const parse = (input: string): string | undefined => { - const url = new URL(input); - const host = url.hostname; - if (!host) { - return undefined; - } - if (host.startsWith('[') && host.endsWith(']')) { - return host.slice(1, -1).toLowerCase(); - } - return host.toLowerCase(); - }; - - for (const candidate of [trimmed, `http://${trimmed}`]) { - try { - const normalized = parse(candidate); - if (normalized) { - return normalized; - } - } catch { - // ignore parse errors and try the next strategy - } - } - - if (trimmed.startsWith('[') && trimmed.endsWith(']')) { - return trimmed.slice(1, -1).toLowerCase(); - } - - if (!trimmed.includes(':')) { - return trimmed.toLowerCase(); - } - - const [host] = trimmed.split(':'); - if (host) { - return host.toLowerCase(); - } - - return trimmed.toLowerCase(); -} - -function isLocalHostname(value: string | undefined | null): boolean { - if (!value) { - return false; - } - const normalized = normalizeHost(value.trim()); - if (!normalized) { - return false; - } - return localHostnames.has(normalized); -} - -function isLocalAddress(value: string | undefined | null): boolean { - if (!value) { - return false; - } - const trimmed = value.trim(); - if (!trimmed) { - return false; - } - const normalized = trimmed.startsWith('::ffff:') ? trimmed.slice('::ffff:'.length) : trimmed; - return localIpAddresses.has(normalized); -} - -function requestFromLocalEnvironment(req: Request): boolean { - if (isLocalHostname(req.hostname)) { - return true; - } - - const forwardedHost = req.get('x-forwarded-host'); - if (forwardedHost && forwardedHost.split(',').some((candidate) => isLocalHostname(candidate))) { - return true; - } - - const origin = req.get('origin'); - if (origin && isLocalHostname(origin)) { - return true; - } - - const forwardedFor = req.get('x-forwarded-for'); - if (forwardedFor) { - const forwardedIps = forwardedFor.split(','); - if (forwardedIps.some((ip) => isLocalAddress(ip))) { - return true; - } - } - - return isLocalAddress(req.ip); -} - if (!isDev && !postmarkServerToken) { console.warn('POSTMARK_SERVER_TOKEN is not set. Login emails will fail until it is configured.'); } @@ -343,37 +246,21 @@ export function createServer(dataStore: SlowpostStore = store) { }); app.post('/api/login/dev-skip', async (req, res) => { - if (!isDev && !requestFromLocalEnvironment(req)) { + if (!isDev) { res.status(404).json({ message: 'Not found' }); return; } try { const schema = z.object({ email: z.string().email(), intent: z.enum(['login', 'signup']).optional() }); - const { email, intent: requestedIntent = 'login' } = schema.parse(req.body); - console.log(`[dev] Skipping PIN verification for ${email} (${requestedIntent})`); - - const session = await (async () => { - try { - return await dataStore.forceVerifyLogin(email, requestedIntent); - } catch (error) { - if ( - requestedIntent === 'login' && - error instanceof Error && - error.message === 'Account not found' - ) { - console.log(`[dev] Account not found for ${email}; creating signup session instead.`); - return await dataStore.forceVerifyLogin(email, 'signup'); - } - throw error; - } - })(); - - if (session.intent === 'login') { + const { email, intent = 'login' } = schema.parse(req.body); + console.log(`[dev] Skipping PIN verification for ${email} (${intent})`); + const session = await dataStore.forceVerifyLogin(email, intent); + if (intent === 'login') { const token = issueLoginToken({ username: session.username, email: session.email }); setLoginCookie(res, token); } - res.json({ username: session.username, intent: session.intent }); + res.json({ username: session.username }); } catch (error) { res.status(400).json({ message: (error as Error).message }); } diff --git a/packages/server/tests/datastore.test.ts b/packages/server/tests/datastore.test.ts index 5afece7..38d1b5e 100644 --- a/packages/server/tests/datastore.test.ts +++ b/packages/server/tests/datastore.test.ts @@ -78,4 +78,13 @@ describe('Slowpost data store', () => { expect(forced.verified).toBe(true); expect(forced.intent).toBe('signup'); }); + + it('updates profile photos', async () => { + const store = createMemoryStore(); + const newPhoto = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; + const profile = await store.updateProfilePhoto('ada', newPhoto); + expect(profile.photoUrl).toBe(newPhoto); + const updatedView = await store.getProfileView('ada'); + expect(updatedView.profile.photoUrl).toBe(newPhoto); + }); });