Testing implementation for private data in ATProto with ATPKeyserver and ATCute tools

everything works now

Changed files
+97 -61
packages
+45 -24
packages/client/app/components/PostFeed.tsx
··· 1 + import { 2 + parseCanonicalResourceUri, 3 + type Did, 4 + type ResourceUri 5 + } from '@atcute/lexicons' 1 6 import type { FeedItem } from '@www/lib/post.server' 7 + import { useRootData } from '@www/lib/useRootData' 2 8 import { Form } from 'react-router' 3 9 4 10 const postVisibilityLabels: Record<string, string> = { ··· 7 13 mentioned: 'Mentioned only' 8 14 } 9 15 16 + function atUriToDid(uri: ResourceUri) { 17 + const parsedUri = parseCanonicalResourceUri(uri) 18 + if (!parsedUri.ok) { 19 + throw new Error(`invalid AT uri: ${parsedUri.error}`) 20 + } 21 + const did = parsedUri.value.repo 22 + return did as Did 23 + } 24 + 10 25 export default function PostFeed({ feed }: { feed: FeedItem[] }) { 26 + const { user } = useRootData() 27 + const me = user?.identity.did 28 + const isMe = (uri: ResourceUri) => atUriToDid(uri) === me 29 + 11 30 return ( 12 31 <ul className="divide-accent-content"> 13 32 {feed.length === 0 ? ( ··· 20 39 key={post.uri} 21 40 className="relative px-4 py-3 mb-6 border border-neutral rounded-box" 22 41 > 23 - <div className="top-1 right-1 absolute"> 24 - <Form method="POST" className="tooltip" data-tip="Delete"> 25 - <input type="hidden" name="uri" value={post.uri} /> 26 - <button 27 - name="action" 28 - value="delete" 29 - className="btn btn-sm btn-square btn-ghost" 30 - > 31 - <svg 32 - xmlns="http://www.w3.org/2000/svg" 33 - className="h-4 w-4" 34 - fill="none" 35 - viewBox="0 0 24 24" 36 - stroke="currentColor" 42 + {isMe(post.uri) ? ( 43 + <div className="top-1 right-1 absolute"> 44 + <Form method="POST" className="tooltip" data-tip="Delete"> 45 + <input type="hidden" name="uri" value={post.uri} /> 46 + <button 47 + name="action" 48 + value="delete" 49 + className="btn btn-sm btn-square btn-ghost" 37 50 > 38 - <path 39 - strokeLinecap="round" 40 - strokeLinejoin="round" 41 - strokeWidth={2} 42 - d="M6 18L18 6M6 6l12 12" 43 - /> 44 - </svg> 45 - </button> 46 - </Form> 47 - </div> 51 + <svg 52 + xmlns="http://www.w3.org/2000/svg" 53 + className="h-4 w-4" 54 + fill="none" 55 + viewBox="0 0 24 24" 56 + stroke="currentColor" 57 + > 58 + <path 59 + strokeLinecap="round" 60 + strokeLinejoin="round" 61 + strokeWidth={2} 62 + d="M6 18L18 6M6 6l12 12" 63 + /> 64 + </svg> 65 + </button> 66 + </Form> 67 + </div> 68 + ) : null} 48 69 <p className="text-sm mb-2 text-base-content/90">@{post.handle}</p> 49 70 <p className="mb-3 text-base"> 50 71 {post.content.contentWarning
+3 -3
packages/client/app/components/UserMenu.tsx
··· 33 33 > 34 34 {avatar ? ( 35 35 <div className="w-10 rounded-full"> 36 - <img src={avatar} alt={handle} className="w-10" /> 36 + <img src={avatar} alt={handle ?? ''} className="w-10" /> 37 37 </div> 38 38 ) : ( 39 39 <div className="bg-accent text-base-content w-10 rounded-full"> ··· 46 46 className="menu menu-sm dropdown-content bg-base-100 rounded-box z-10 mt-1 w-52 p-2 shadow" 47 47 > 48 48 <li> 49 - <a>Profile</a> 49 + <Link to={`/profile/${handle}`}>Your profile</Link> 50 50 </li> 51 51 <li> 52 - <Link to="/settings/delegation">Delegation Settings</Link> 52 + <Link to="/settings/delegation">Private posts setup</Link> 53 53 </li> 54 54 <li> 55 55 <button onClick={logout}>Logout</button>
+20 -20
packages/client/app/lib/post.server.ts
··· 25 25 import type { OAuthSession } from '@atproto/oauth-client-node' 26 26 import type { AtprotoDid as Did, ResourceUri } from '@atcute/lexicons/syntax' 27 27 import { atUriToDid, didToHandle, handleToDid } from './idResolver.server' 28 + import asyncWrap from './asyncWrap' 28 29 29 30 type PostPayload = { 30 31 visibility: AppWafrnContentDefs.PrivatePostView['visibility'] ··· 158 159 visibility: 'public' 159 160 }) 160 161 } else if (is(AppWafrnContentDefs.privatePostViewSchema, record)) { 161 - try { 162 - const plainText = await keyClient.decrypt( 162 + const [plainText, error] = await asyncWrap(() => 163 + keyClient.decrypt( 163 164 record.uri, 164 165 `${did}#${record.visibility}`, 165 166 record.encryptedContent, 166 167 record.keyVersion 167 168 ) 168 - const postContent = JSON.parse( 169 - plainText 170 - ) as AppWafrnContentDefs.PostContent 169 + ) 170 + let content = {} as AppWafrnContentDefs.PostContent 171 + if (error) { 172 + console.error(error) 173 + const isMe = session.did === did 174 + // only show "could not decrypt this post" if it's the user's post 175 + if (isMe) { 176 + content = { 177 + contentMarkdown: 'Could not decrypt this post', 178 + contentHTML: 'Could not decrypt this post' 179 + } 180 + } 181 + } else { 182 + content = JSON.parse(plainText) as AppWafrnContentDefs.PostContent 183 + } 184 + if (content.contentMarkdown || content.contentHTML) { 171 185 posts.push({ 172 186 $type: 'app.wafrn.content.defs#publicPostView', 173 187 handle, 174 188 uri: record.uri, 175 - content: postContent, 176 - createdAt: record.createdAt, 177 - updatedAt: record.updatedAt, 178 - visibility: record.visibility 179 - }) 180 - } catch (error) { 181 - console.error('Error decrypting private post:', error) 182 - posts.push({ 183 - $type: 'app.wafrn.content.defs#publicPostView', 184 - handle, 185 - uri: record.uri, 186 - content: { 187 - contentMarkdown: 'Could not decrypt this post', 188 - contentHTML: 'Could not decrypt this post' 189 - }, 189 + content, 190 190 createdAt: record.createdAt, 191 191 updatedAt: record.updatedAt, 192 192 visibility: record.visibility
+29 -14
packages/client/app/routes/settings.delegation.tsx
··· 43 43 delegate_did: env.API_SERVICE_DID, 44 44 permissions: ['add_member', 'remove_member'] 45 45 }) 46 - return data({ success: true, message: 'Server authorized successfully!' }) 46 + return data({ 47 + success: true, 48 + message: 49 + 'Private posts enabled! You can now create posts that only your followers can read.' 50 + }) 47 51 } else if (action === 'revoke') { 48 52 await keyClient.revokeDelegate({ 49 53 group_id: groupId, 50 54 delegate_did: env.API_SERVICE_DID 51 55 }) 52 - return data({ success: true, message: 'Server authorization revoked!' }) 56 + return data({ 57 + success: true, 58 + message: 59 + 'Private posts disabled. The server can no longer manage your follower encryption group.' 60 + }) 53 61 } 54 62 } catch (error) { 55 63 return data( ··· 73 81 74 82 return ( 75 83 <div className="px-4 py-6 max-w-3xl mx-auto"> 76 - <h1 className="text-2xl font-bold mb-3">Delegation Settings</h1> 84 + <h1 className="text-2xl font-bold mb-3">Private Post Setup</h1> 77 85 78 86 <div className="card bg-base-200 shadow-xl mb-4"> 79 87 <div className="card-body"> 80 - <h2 className="card-title">Server Authorization</h2> 88 + <h2 className="card-title"> 89 + Let the server manage your follower encryption 90 + </h2> 81 91 <p className="text-sm opacity-70 mb-4"> 82 - Authorize the application server to manage your follower group on 83 - the keyserver. This allows the server to automatically add followers 84 - to your encryption group when they follow you. 92 + Want to create private posts that only your followers can read? 93 + You'll need to authorize the server to manage your follower 94 + encryption group. When someone follows you, the server will 95 + automatically add them to the group so they can decrypt your private 96 + posts. Without this, private posts won't work unless you enable 97 + manual follow approval (not implemented yet). 85 98 </p> 86 99 87 100 <div className="grid grid-cols-2 gap-2 text-sm mb-4"> ··· 94 107 <div className="font-semibold">Server DID:</div> 95 108 <div className="font-mono text-xs break-all">{apiServiceDid}</div> 96 109 97 - <div className="font-semibold">Status:</div> 110 + <div className="font-semibold">Private Posts:</div> 98 111 <div> 99 112 {isAuthorized ? ( 100 - <span className="badge badge-success">Authorized</span> 113 + <span className="badge badge-success">Enabled</span> 101 114 ) : ( 102 - <span className="badge badge-warning">Not Authorized</span> 115 + <span className="badge badge-warning">Disabled</span> 103 116 )} 104 117 </div> 105 118 </div> 106 119 107 120 {actionData?.message ? ( 108 121 <div 109 - className={`alert ${actionData.success ? 'alert-success' : 'alert-error'} mb-4`} 122 + className={`alert alert-soft ${actionData.success ? 'alert-success' : 'alert-error'} mb-4`} 110 123 > 111 124 {actionData.message} 112 125 </div> 113 126 ) : null} 114 127 115 128 {error ? ( 116 - <div className="alert alert-error mb-4">{error.message}</div> 129 + <div className="alert alert-soft alert-error mb-4"> 130 + {error.message} 131 + </div> 117 132 ) : null} 118 133 119 134 <Form method="POST" className="card-actions"> ··· 124 139 className="btn btn-warning" 125 140 type="submit" 126 141 > 127 - Revoke Authorization 142 + Turn Off (Disable Private Posts) 128 143 </button> 129 144 ) : ( 130 145 <button ··· 133 148 className="btn btn-primary" 134 149 type="submit" 135 150 > 136 - Authorize Server 151 + Enable Private Posts 137 152 </button> 138 153 )} 139 154 </Form>