···85858686#### Install the root certificate on your machine
87878888-First, get your Caddy container ID:
8989-9090-```bash
9191-docker ps
9292-```
9393-9494-Then copy the cert out:
8888+Copy the cert out:
95899690```bash
9797-docker cp <caddy_container_id>:/data/pki/authorities/grain/root.crt ./grain-root.crt
9191+docker cp caddy:/data/pki/authorities/grain/root.crt ./grain-root.crt
9892```
999310094Once you have grain-root.crt, install it:
+50
__generated__/index.ts
···3939export class Server {
4040 xrpc: XrpcServer
4141 app: AppNS
4242+ sh: ShNS
4243 social: SocialNS
4344 com: ComNS
44454546 constructor(options?: XrpcOptions) {
4647 this.xrpc = createXrpcServer(schemas, options)
4748 this.app = new AppNS(this)
4949+ this.sh = new ShNS(this)
4850 this.social = new SocialNS(this)
4951 this.com = new ComNS(this)
5052 }
···118120 }
119121}
120122123123+export class ShNS {
124124+ _server: Server
125125+ tangled: ShTangledNS
126126+127127+ constructor(server: Server) {
128128+ this._server = server
129129+ this.tangled = new ShTangledNS(server)
130130+ }
131131+}
132132+133133+export class ShTangledNS {
134134+ _server: Server
135135+ graph: ShTangledGraphNS
136136+ actor: ShTangledActorNS
137137+138138+ constructor(server: Server) {
139139+ this._server = server
140140+ this.graph = new ShTangledGraphNS(server)
141141+ this.actor = new ShTangledActorNS(server)
142142+ }
143143+}
144144+145145+export class ShTangledGraphNS {
146146+ _server: Server
147147+148148+ constructor(server: Server) {
149149+ this._server = server
150150+ }
151151+}
152152+153153+export class ShTangledActorNS {
154154+ _server: Server
155155+156156+ constructor(server: Server) {
157157+ this._server = server
158158+ }
159159+}
160160+121161export class SocialNS {
122162 _server: Server
123163 grain: SocialGrainNS
···131171export class SocialGrainNS {
132172 _server: Server
133173 gallery: SocialGrainGalleryNS
174174+ graph: SocialGrainGraphNS
134175 actor: SocialGrainActorNS
135176 photo: SocialGrainPhotoNS
136177137178 constructor(server: Server) {
138179 this._server = server
139180 this.gallery = new SocialGrainGalleryNS(server)
181181+ this.graph = new SocialGrainGraphNS(server)
140182 this.actor = new SocialGrainActorNS(server)
141183 this.photo = new SocialGrainPhotoNS(server)
142184 }
143185}
144186145187export class SocialGrainGalleryNS {
188188+ _server: Server
189189+190190+ constructor(server: Server) {
191191+ this._server = server
192192+ }
193193+}
194194+195195+export class SocialGrainGraphNS {
146196 _server: Server
147197148198 constructor(server: Server) {
···2222 },
2323 "reason": {
2424 "type": "string",
2525- "description": "Expected values are 'gallery-favorite', and 'unknown'.",
2525+ "description": "The reason why this notification was delivered - e.g. your gallery was favd, or you received a new follower.",
2626 "knownValues": [
2727+ "follow",
2728 "gallery-favorite",
2829 "unknown"
2930 ]
···11+# You do not necessarily need to fill this file out, this is just for reference
22+# If you do want to use the official pdsadmin tool, then you should fill this out
33+44+# public
55+PDS_HOSTNAME=pds.example.com
66+PDS_DATA_DIRECTORY=/pds
77+PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks
88+PDS_BLOB_UPLOAD_LIMIT=52428800
99+PDS_DID_PLC_URL=https://plc.directory
1010+PDS_BSKY_APP_VIEW_URL=https://api.bsky.app
1111+PDS_BSKY_APP_VIEW_DID=did:web:api.bsky.app
1212+PDS_REPORT_SERVICE_URL=https://mod.bsky.app
1313+PDS_REPORT_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac
1414+PDS_CRAWLERS=https://bsky.network
1515+LOG_ENABLED=true
1616+PDS_SERVICE_HANDLE_DOMAINS=.example.com
1717+1818+# private
1919+PDS_JWT_SECRET=<secret>
2020+PDS_ADMIN_PASSWORD=<secret>
2121+PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=<secret>
2222+2323+# private: email
2424+PDS_EMAIL_SMTP_URL=smtps://resend:<secret api key here>@smtp.resend.com:465/
2525+PDS_EMAIL_FROM_ADDRESS=support@your.domain
+65
services/pds/README.md
···11+# Setting up a PDS
22+33+1. Customizing _fly.toml_
44+ - You should replace values `app`, `primary_region`, `env.PDS_HOSTNAME` to
55+ values that will make sense for your installation.
66+ - `app` controls the name of the project on fly.io
77+ - `primary_region` controls where the app will be deployed globally, `iad`
88+ is in Northern Virginia (USA)
99+ - `[env]`, `PDS_HOSTNAME` should make the URL from where you plan to reach
1010+ the application, so for example, if you're planning to add a DNS entry to
1111+ reach your PDS from `my-pds.my-site.com`, then, use that as the value
1212+ here
1313+2. Generate the necessary secret values for your PDS
1414+ > 🚧 All of these values are super secret, do not share them!
1515+ >
1616+ > Make sure you have them written down somewhere because fly.io will never
1717+ > let you see them again
1818+ 1. _PDS_JWT_SECRET_: `openssl rand --hex 16`
1919+ 2. _PDS_ADMIN_PASSWORD_: `openssl rand --hex 16`
2020+ 3. _PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX_:
2121+ `openssl ecparam --name secp256k1 --genkey --noout --outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32`
2222+3. Create the project in fly.io
2323+ 1. Run `fly launch --no-deploy`, this will create the project on fly without
2424+ deploying it. You need to make some changes ahead of an initial deployment
2525+ 2. Create the volume that you specified earlier, make sure to choose the
2626+ primary_region as the region for your volume `fly volume create pdsdata`
2727+ 3. Apply the secrets you generated earlier
2828+ `fly secrets set PDS_JWT_SECRET=secret PDS_ADMIN_PASSWORD=secret PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=secret`
2929+ 4. Deploy the app using `fly deploy`
3030+ > 🚧 This should create only one machine, make sure using `fly m ls`
3131+ >
3232+ > If you have more than one machine scale down using `fly scale count 1`
3333+ 5. Test your PDS: You can do this quickly by visitng
3434+ `https://<your-app-name>.fly.dev/xrpc/com.atproto.sync.listRepos`, at this
3535+ point you should see a response like this:
3636+ ```json
3737+ { "repos": [] }
3838+ ```
3939+4. Setup your DNS
4040+4141+- You need to create an entry for your PDS's hostname in the DNS console you use
4242+ for your domain name: `pds.example.com`
4343+4444+> 🚧 You need to create an entry that allows you to map handles to the pds
4545+>
4646+> The handle `username.pds.example.com` needs be able to resolve, so your PDS
4747+> should also be available at `username.pds.example.com`. If you don't do this,
4848+> other atproto services can't resolve the handle and you get `Invalid Handle`
4949+> everywhere you go
5050+5151+- Now you should be able to reach your PDS at
5252+ `https://pds.example.com/xrpc/com.atproto.sync.listRepos`
5353+5454+5. Bonus, Setting up emails: Blue Sky will ask you to verify your email, but,
5555+ without having a mail service setup, you'll never be able to get the
5656+ confirmation code! Follow the official PDS guide on setting up email
5757+ services, it covers the topic fully:
5858+ [link](https://github.com/bluesky-social/pds?tab=readme-ov-file#setting-up-smtp)
5959+ > 🚧 Remember: You can add secrets to your fly service using
6060+ >
6161+ > `fly secrets set KEY1=VALUE1 KEY2=VALE2 ...`
6262+6363+# Credits
6464+6565+[keaysma](https://github.com/keaysma/pds-fly.io-template)
+234
services/pds/account.sh
···11+#!/bin/bash
22+set -o errexit
33+set -o nounset
44+set -o pipefail
55+66+PDS_ENV_FILE=${PDS_ENV_FILE:-".env"}
77+source "${PDS_ENV_FILE}"
88+99+# curl a URL and fail if the request fails.
1010+function curl_cmd_get {
1111+ curl --fail --silent --show-error "$@"
1212+}
1313+1414+# curl a URL and fail if the request fails.
1515+function curl_cmd_post {
1616+ curl --fail --silent --show-error --request POST --header "Content-Type: application/json" "$@"
1717+}
1818+1919+# curl a URL but do not fail if the request fails.
2020+function curl_cmd_post_nofail {
2121+ curl --silent --show-error --request POST --header "Content-Type: application/json" "$@"
2222+}
2323+2424+# The subcommand to run.
2525+SUBCOMMAND="${1:-}"
2626+2727+#
2828+# account list
2929+#
3030+if [[ "${SUBCOMMAND}" == "list" ]]; then
3131+ DIDS="$(curl_cmd_get \
3232+ "https://${PDS_HOSTNAME}/xrpc/com.atproto.sync.listRepos?limit=100" | jq --raw-output '.repos[].did'
3333+ )"
3434+ OUTPUT='[{"handle":"Handle","email":"Email","did":"DID"}'
3535+ for did in ${DIDS}; do
3636+ ITEM="$(curl_cmd_get \
3737+ --user "admin:${PDS_ADMIN_PASSWORD}" \
3838+ "https://${PDS_HOSTNAME}/xrpc/com.atproto.admin.getAccountInfo?did=${did}"
3939+ )"
4040+ OUTPUT="${OUTPUT},${ITEM}"
4141+ done
4242+ OUTPUT="${OUTPUT}]"
4343+ echo "${OUTPUT}" | jq --raw-output '.[] | [.handle, .email, .did] | @tsv' | column -t
4444+4545+#
4646+# account create
4747+#
4848+elif [[ "${SUBCOMMAND}" == "create" ]]; then
4949+ EMAIL="${2:-}"
5050+ HANDLE="${3:-}"
5151+5252+ if [[ "${EMAIL}" == "" ]]; then
5353+ read -p "Enter an email address (e.g. alice@${PDS_HOSTNAME}): " EMAIL
5454+ fi
5555+ if [[ "${HANDLE}" == "" ]]; then
5656+ read -p "Enter a handle (e.g. alice.${PDS_HOSTNAME}): " HANDLE
5757+ fi
5858+5959+ if [[ "${EMAIL}" == "" || "${HANDLE}" == "" ]]; then
6060+ echo "ERROR: missing EMAIL and/or HANDLE parameters." >/dev/stderr
6161+ echo "Usage: $0 ${SUBCOMMAND} <EMAIL> <HANDLE>" >/dev/stderr
6262+ exit 1
6363+ fi
6464+6565+ PASSWORD="$(openssl rand -base64 30 | tr -d "=+/" | cut -c1-24)"
6666+ INVITE_CODE="$(curl_cmd_post \
6767+ --user "admin:${PDS_ADMIN_PASSWORD}" \
6868+ --data '{"useCount": 1}' \
6969+ "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createInviteCode" | jq --raw-output '.code'
7070+ )"
7171+ RESULT="$(curl_cmd_post_nofail \
7272+ --data "{\"email\":\"${EMAIL}\", \"handle\":\"${HANDLE}\", \"password\":\"${PASSWORD}\", \"inviteCode\":\"${INVITE_CODE}\"}" \
7373+ "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createAccount"
7474+ )"
7575+7676+ DID="$(echo $RESULT | jq --raw-output '.did')"
7777+ if [[ "${DID}" != did:* ]]; then
7878+ ERR="$(echo ${RESULT} | jq --raw-output '.message')"
7979+ echo "ERROR: ${ERR}" >/dev/stderr
8080+ echo "Usage: $0 ${SUBCOMMAND} <EMAIL> <HANDLE>" >/dev/stderr
8181+ exit 1
8282+ fi
8383+8484+ echo
8585+ echo "Account created successfully!"
8686+ echo "-----------------------------"
8787+ echo "Handle : ${HANDLE}"
8888+ echo "DID : ${DID}"
8989+ echo "Password : ${PASSWORD}"
9090+ echo "-----------------------------"
9191+ echo "Save this password, it will not be displayed again."
9292+ echo
9393+9494+#
9595+# account delete
9696+#
9797+elif [[ "${SUBCOMMAND}" == "delete" ]]; then
9898+ DID="${2:-}"
9999+100100+ if [[ "${DID}" == "" ]]; then
101101+ echo "ERROR: missing DID parameter." >/dev/stderr
102102+ echo "Usage: $0 ${SUBCOMMAND} <DID>" >/dev/stderr
103103+ exit 1
104104+ fi
105105+106106+ if [[ "${DID}" != did:* ]]; then
107107+ echo "ERROR: DID parameter must start with \"did:\"." >/dev/stderr
108108+ echo "Usage: $0 ${SUBCOMMAND} <DID>" >/dev/stderr
109109+ exit 1
110110+ fi
111111+112112+ echo "This action is permanent."
113113+ read -r -p "Are you sure you'd like to delete ${DID}? [y/N] " response
114114+ if [[ ! "${response}" =~ ^([yY][eE][sS]|[yY])$ ]]; then
115115+ exit 0
116116+ fi
117117+118118+ curl_cmd_post \
119119+ --user "admin:${PDS_ADMIN_PASSWORD}" \
120120+ --data "{\"did\": \"${DID}\"}" \
121121+ "https://${PDS_HOSTNAME}/xrpc/com.atproto.admin.deleteAccount" >/dev/null
122122+123123+ echo "${DID} deleted"
124124+125125+#
126126+# account takedown
127127+#
128128+elif [[ "${SUBCOMMAND}" == "takedown" ]]; then
129129+ DID="${2:-}"
130130+ TAKEDOWN_REF="$(date +%s)"
131131+132132+ if [[ "${DID}" == "" ]]; then
133133+ echo "ERROR: missing DID parameter." >/dev/stderr
134134+ echo "Usage: $0 ${SUBCOMMAND} <DID>" >/dev/stderr
135135+ exit 1
136136+ fi
137137+138138+ if [[ "${DID}" != did:* ]]; then
139139+ echo "ERROR: DID parameter must start with \"did:\"." >/dev/stderr
140140+ echo "Usage: $0 ${SUBCOMMAND} <DID>" >/dev/stderr
141141+ exit 1
142142+ fi
143143+144144+ PAYLOAD="$(cat <<EOF
145145+ {
146146+ "subject": {
147147+ "\$type": "com.atproto.admin.defs#repoRef",
148148+ "did": "${DID}"
149149+ },
150150+ "takedown": {
151151+ "applied": true,
152152+ "ref": "${TAKEDOWN_REF}"
153153+ }
154154+ }
155155+EOF
156156+)"
157157+158158+ curl_cmd_post \
159159+ --user "admin:${PDS_ADMIN_PASSWORD}" \
160160+ --data "${PAYLOAD}" \
161161+ "https://${PDS_HOSTNAME}/xrpc/com.atproto.admin.updateSubjectStatus" >/dev/null
162162+163163+ echo "${DID} taken down"
164164+165165+#
166166+# account untakedown
167167+#
168168+elif [[ "${SUBCOMMAND}" == "untakedown" ]]; then
169169+ DID="${2:-}"
170170+171171+ if [[ "${DID}" == "" ]]; then
172172+ echo "ERROR: missing DID parameter." >/dev/stderr
173173+ echo "Usage: $0 ${SUBCOMMAND} <DID>" >/dev/stderr
174174+ exit 1
175175+ fi
176176+177177+ if [[ "${DID}" != did:* ]]; then
178178+ echo "ERROR: DID parameter must start with \"did:\"." >/dev/stderr
179179+ echo "Usage: $0 ${SUBCOMMAND} <DID>" >/dev/stderr
180180+ exit 1
181181+ fi
182182+183183+ PAYLOAD=$(cat <<EOF
184184+ {
185185+ "subject": {
186186+ "\$type": "com.atproto.admin.defs#repoRef",
187187+ "did": "${DID}"
188188+ },
189189+ "takedown": {
190190+ "applied": false
191191+ }
192192+ }
193193+EOF
194194+)
195195+196196+ curl_cmd_post \
197197+ --user "admin:${PDS_ADMIN_PASSWORD}" \
198198+ --data "${PAYLOAD}" \
199199+ "https://${PDS_HOSTNAME}/xrpc/com.atproto.admin.updateSubjectStatus" >/dev/null
200200+201201+ echo "${DID} untaken down"
202202+#
203203+# account reset-password
204204+#
205205+elif [[ "${SUBCOMMAND}" == "reset-password" ]]; then
206206+ DID="${2:-}"
207207+ PASSWORD="$(openssl rand -base64 30 | tr -d "=+/" | cut -c1-24)"
208208+209209+ if [[ "${DID}" == "" ]]; then
210210+ echo "ERROR: missing DID parameter." >/dev/stderr
211211+ echo "Usage: $0 ${SUBCOMMAND} <DID>" >/dev/stderr
212212+ exit 1
213213+ fi
214214+215215+ if [[ "${DID}" != did:* ]]; then
216216+ echo "ERROR: DID parameter must start with \"did:\"." >/dev/stderr
217217+ echo "Usage: $0 ${SUBCOMMAND} <DID>" >/dev/stderr
218218+ exit 1
219219+ fi
220220+221221+ curl_cmd_post \
222222+ --user "admin:${PDS_ADMIN_PASSWORD}" \
223223+ --data "{ \"did\": \"${DID}\", \"password\": \"${PASSWORD}\" }" \
224224+ "https://${PDS_HOSTNAME}/xrpc/com.atproto.admin.updateAccountPassword" >/dev/null
225225+226226+ echo
227227+ echo "Password reset for ${DID}"
228228+ echo "New password: ${PASSWORD}"
229229+ echo
230230+231231+else
232232+ echo "Unknown subcommand: ${SUBCOMMAND}" >/dev/stderr
233233+ exit 1
234234+fi
···11+# pdsadmin is a tool for managing the Personal Data Store (PDS) server.
22+# But at the end of the day, it's just a bash script that makes curl requests
33+# Even worse, it does all sorts of annoying checks that don't apply to OSX
44+# So I have reversed engineered the requests I cared about and put them here
55+66+# You can copy and paste these into your terminal,
77+# Remove the underscores before the curl command
88+# Replace the variables with your own values
99+1010+PDS_ENV_FILE=${PDS_ENV_FILE:-".env"}
1111+source "${PDS_ENV_FILE}"
1212+1313+export DID=""
1414+1515+# make an invite code
1616+curl \
1717+ --fail \
1818+ --silent \
1919+ --show-error \
2020+ --request POST \
2121+ --header "Content-Type: application/json" \
2222+ --user "admin:${PDS_ADMIN_PASSWORD}" \
2323+ --data '{"useCount": 20}' \
2424+ "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createInviteCode"
2525+2626+# delete an account
2727+# curl \
2828+# --fail \
2929+# --silent \
3030+# --show-error \
3131+# --request POST \
3232+# --header "Content-Type: application/json" \
3333+# --user "admin:${PDS_ADMIN_PASSWORD}" \
3434+# --data "{\"did\": \"${DID}\"}" \
3535+# "https://${PDS_HOST}/xrpc/com.atproto.admin.deleteAccount"
···1919 halt the event
2020 put 'Updating...' into #submit-button.innerText
2121 add @disabled to #submit-button
2222- call Grain.updateProfile(me)
2222+ call Grain.profileDialog.updateProfile(me)
2323 on htmx:afterOnLoad
2424 put 'Update' into #submit-button.innerText
2525 remove @disabled from #submit-button
···11+import { ComponentChildren } from "preact";
22+import { Breadcrumb } from "./components/Breadcrumb.tsx";
33+44+type SectionProps = {
55+ title: string;
66+ children: ComponentChildren;
77+};
88+99+const Section = ({ title, children }: SectionProps) => (
1010+ <section className="mb-8">
1111+ <h2 className="text-xl font-bold mb-2 text-zinc-800 dark:text-zinc-100">
1212+ {title}
1313+ </h2>
1414+ <div className="space-y-2 text-zinc-700 dark:text-zinc-300 text-sm">
1515+ {children}
1616+ </div>
1717+ </section>
1818+);
1919+2020+export function Terms() {
2121+ return (
2222+ <div className="px-4 py-4">
2323+ <Breadcrumb
2424+ items={[{ label: "support", href: "/support" }, { label: "terms" }]}
2525+ />
2626+ <h1 className="text-3xl font-bold mb-6 text-zinc-900 dark:text-white">
2727+ Terms and Conditions
2828+ </h1>
2929+ <div className="mb-6 text-sm text-zinc-900 dark:text-white">
3030+ Last Updated: June 3, 2025
3131+ </div>
3232+ <Section title="Overview">
3333+ <p>
3434+ Grain is a photo sharing app built on the{" "}
3535+ <a
3636+ href="https://atproto.com/"
3737+ className="text-sky-500 hover:underline"
3838+ target="_blank"
3939+ rel="noopener noreferrer"
4040+ />
4141+ AT Protocol . All data, including photos, galleries, favorites, and
4242+ metadata, is public and stored on the AT Protocol network. Users can
4343+ upload photos, create and favorite galleries, and view non-location
4444+ EXIF metadata.
4545+ </p>
4646+ <p>
4747+ Grain is an open source project. These Terms apply to your use of the
4848+ hosted version at{" "}
4949+ <code>grain.social</code>, not to self-hosted instances or forks of
5050+ the source code.
5151+ </p>
5252+ </Section>
5353+5454+ <Section title="Account and Data Ownership">
5555+ <p>
5656+ Grain uses the AT Protocol, so users retain full control over their
5757+ data. We are an independent project and not affiliated with Bluesky or
5858+ the AT Protocol.
5959+ </p>
6060+ <p>
6161+ If you use a <code>grain.social</code>{" "}
6262+ handle, your data may be stored on our own self-hosted{" "}
6363+ <a
6464+ href="https://atproto.com/guides/glossary#pds-personal-data-server"
6565+ className="text-sky-500 hover:underline"
6666+ target="_blank"
6767+ rel="noopener noreferrer"
6868+ >
6969+ PDS (Personal Data Server)
7070+ </a>{" "}
7171+ in accordance with protocol standards.
7272+ </p>
7373+ </Section>
7474+7575+ <Section title="Content">
7676+ <p>
7777+ You are responsible for any content you share. Do not upload content
7878+ you do not have rights to. All uploads are publicly visible and cannot
7979+ currently be set as private.
8080+ </p>
8181+ </Section>
8282+8383+ <Section title="Analytics">
8484+ <p>
8585+ We use{" "}
8686+ <a
8787+ href="https://www.goatcounter.com/"
8888+ className="text-sky-500 hover:underline"
8989+ >
9090+ Goatcounter
9191+ </a>{" "}
9292+ for basic analytics. No personal data is collected, tracked, or sold.
9393+ </p>
9494+ </Section>
9595+9696+ <Section title="Prohibited Conduct">
9797+ <p>
9898+ Do not upload illegal content, harass users, impersonate others, or
9999+ attempt to disrupt the network.
100100+ </p>
101101+ </Section>
102102+103103+ <Section title="Disclaimers">
104104+ <p>
105105+ Grain is provided "as is." We do not guarantee uptime, data retention,
106106+ or uninterrupted access.
107107+ </p>
108108+ </Section>
109109+110110+ <Section title="Termination">
111111+ <p>
112112+ We reserve the right to suspend or terminate your access to Grain at
113113+ any time, without prior notice, for conduct that we believe violates
114114+ these Terms, our community standards, or is harmful to other users or
115115+ the AT Protocol network. Terminated accounts may lose access to
116116+ uploaded content unless retained through the protocol’s data
117117+ persistence mechanisms.
118118+ </p>
119119+ </Section>
120120+121121+ <Section title="Changes">
122122+ <p>
123123+ We may update these terms periodically. Continued use means acceptance
124124+ of any changes.
125125+ </p>
126126+ </Section>
127127+128128+ <Section title="Contact">
129129+ <p>
130130+ For any questions about these Terms, your account, or issues with the
131131+ app, you can contact us at{" "}
132132+ <a
133133+ href="mailto:support@grain.social"
134134+ className="text-sky-500 hover:underline"
135135+ >
136136+ support@grain.social
137137+ </a>.
138138+ </p>
139139+ </Section>
140140+ </div>
141141+ );
142142+}
143143+144144+export function PrivacyPolicy() {
145145+ return (
146146+ <div className="px-4 py-4">
147147+ <Breadcrumb
148148+ items={[{ label: "support", href: "/support" }, { label: "privacy" }]}
149149+ />
150150+ <h1 className="text-3xl font-bold mb-6 text-zinc-900 dark:text-white">
151151+ Privacy Policy
152152+ </h1>
153153+ <div className="mb-6 text-sm text-zinc-900 dark:text-white">
154154+ Last Updated: June 3, 2025
155155+ </div>
156156+ <Section title="Data Storage and Access">
157157+ <p>
158158+ Your data is stored on the AT Protocol. If you use a{" "}
159159+ <code>grain.social</code>{" "}
160160+ handle, it may be stored on our PDS. We do not store or access data
161161+ beyond the protocol’s standard behavior.
162162+ </p>
163163+ </Section>
164164+165165+ <Section title="Public Data">
166166+ <p>
167167+ All content on Grain is public. Private uploads are not currently
168168+ supported.
169169+ </p>
170170+ </Section>
171171+172172+ {
173173+ /* Coming soon */
174174+ /* <Section title="EXIF Metadata">
175175+ <p>
176176+ We optionally collect and display EXIF metadata (excluding location)
177177+ from your photos. At upload time, you can choose whether to allow this
178178+ metadata to be collected. The metadata is stored according to standard
179179+ AT Protocol storage mechanisms and is not retained outside the
180180+ protocol or used for other purposes.
181181+ </p>
182182+ <p>
183183+ You can learn more about the types of metadata commonly embedded in
184184+ photos at{" "}
185185+ <a
186186+ href="https://exiv2.org/tags.html"
187187+ className="text-sky-500 hover:underline"
188188+ target="_blank"
189189+ rel="noopener noreferrer"
190190+ >
191191+ exiv2.org
192192+ </a>
193193+ .
194194+ </p>
195195+ </Section> */
196196+ }
197197+198198+ <Section title="Analytics">
199199+ <p>
200200+ We use{" "}
201201+ <a
202202+ href="https://www.goatcounter.com/"
203203+ className="text-sky-500 hover:underline"
204204+ >
205205+ Goatcounter
206206+ </a>{" "}
207207+ for analytics. It is privacy-focused: no IP addresses, cookies, or
208208+ personal data is collected.
209209+ </p>
210210+ </Section>
211211+212212+ <Section title="No Ads or Tracking">
213213+ <p>We do not serve ads, use third-party tracking, or sell user data.</p>
214214+ </Section>
215215+216216+ <Section title="Children’s Privacy">
217217+ <p>Grain is not intended for users under 13 years of age.</p>
218218+ </Section>
219219+220220+ <Section title="Changes to Policy">
221221+ <p>
222222+ This policy may be updated. Material changes will be communicated via
223223+ the app or site.
224224+ </p>
225225+ </Section>
226226+227227+ <Section title="Contact">
228228+ <p>
229229+ For privacy questions, contact us at{" "}
230230+ <a
231231+ href="mailto:support@grain.social"
232232+ className="text-sky-500 hover:underline"
233233+ >
234234+ support@grain.social
235235+ </a>.
236236+ </p>
237237+ </Section>
238238+ </div>
239239+ );
240240+}
241241+242242+export function CopyrightPolicy() {
243243+ return (
244244+ <div className="px-4 py-4">
245245+ <Breadcrumb
246246+ items={[{ label: "support", href: "/support" }, { label: "copyright" }]}
247247+ />
248248+ <h1 className="text-3xl font-bold mb-6 text-zinc-900 dark:text-white">
249249+ Copyright Policy
250250+ </h1>
251251+ <div className="mb-6 text-sm text-zinc-900 dark:text-white">
252252+ Last Updated: June 3, 2025
253253+ </div>
254254+ <Section title="Copyright Infringement">
255255+ <p>
256256+ Grain respects the intellectual property rights of others and expects
257257+ users to do the same. If you believe your copyrighted work has been
258258+ used in a way that constitutes infringement, please notify us
259259+ promptly.
260260+ </p>
261261+ </Section>
262262+263263+ <Section title="Notice Requirements">
264264+ <p>
265265+ Your infringement notice must include: (1) a description of the
266266+ copyrighted work, (2) the location of the infringing material, (3)
267267+ your contact information, (4) a statement that you believe in good
268268+ faith the use is not authorized, and (5) a statement, under penalty of
269269+ perjury, that the information is accurate.
270270+ </p>
271271+ </Section>
272272+273273+ <Section title="DMCA Compliance">
274274+ <p>
275275+ Grain complies with the Digital Millennium Copyright Act (DMCA). If
276276+ you are a copyright holder and believe your rights have been violated,
277277+ you may file a DMCA notice with the required information to our
278278+ designated agent. We will promptly respond to all valid DMCA notices
279279+ and take appropriate action, including removal of the infringing
280280+ content and disabling access.
281281+ </p>
282282+ </Section>
283283+284284+ <Section title="Repeat Infringers">
285285+ <p>
286286+ Accounts that repeatedly infringe copyright may be suspended or
287287+ removed in accordance with AT Protocol and Grain Social’s moderation
288288+ guidelines.
289289+ </p>
290290+ </Section>
291291+292292+ <Section title="Contact">
293293+ <p>
294294+ To report infringement or submit a DMCA notice, contact us at{" "}
295295+ <a
296296+ href="mailto:support@grain.social"
297297+ className="text-sky-500 hover:underline"
298298+ >
299299+ support@grain.social
300300+ </a>.
301301+ </p>
302302+ </Section>
303303+ </div>
304304+ );
305305+}
+149-3
src/lib/actor.ts
···11+import { Record as BskyProfile } from "$lexicon/types/app/bsky/actor/profile.ts";
22+import { Record as TangledProfile } from "$lexicon/types/sh/tangled/actor/profile.ts";
13import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts";
22-import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts";
44+import { Record as GrainProfile } from "$lexicon/types/social/grain/actor/profile.ts";
55+import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts";
36import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts";
47import { Record as Photo } from "$lexicon/types/social/grain/photo.ts";
58import { Record as PhotoExif } from "$lexicon/types/social/grain/photo/exif.ts";
···710import { BffContext, WithBffMeta } from "@bigmoves/bff";
811import { galleryToView, getGalleryItemsAndPhotos } from "./gallery.ts";
912import { photoToView } from "./photo.ts";
1313+import type { SocialNetwork } from "./timeline.ts";
10141115export function getActorProfile(did: string, ctx: BffContext) {
1216 const actor = ctx.indexService.getActor(did);
1317 if (!actor) return null;
1414- const profileRecord = ctx.indexService.getRecord<WithBffMeta<Profile>>(
1818+ const profileRecord = ctx.indexService.getRecord<WithBffMeta<GrainProfile>>(
1519 `at://${did}/social.grain.actor.profile/self`,
1620 );
1721 return profileRecord ? profileToView(profileRecord, actor.handle) : null;
1822}
19232024export function profileToView(
2121- record: WithBffMeta<Profile>,
2525+ record: WithBffMeta<GrainProfile>,
2226 handle: string,
2327): Un$Typed<ProfileView> {
2428 return {
···34383539export function getActorPhotos(handleOrDid: string, ctx: BffContext) {
3640 let did: string;
4141+3742 if (handleOrDid.includes("did:")) {
3843 did = handleOrDid;
3944 } else {
···4146 if (!actor) return [];
4247 did = actor.did;
4348 }
4949+4450 const photos = ctx.indexService.getRecords<WithBffMeta<Photo>>(
4551 "social.grain.photo",
4652 {
···66726773export function getActorGalleries(handleOrDid: string, ctx: BffContext) {
6874 let did: string;
7575+6976 if (handleOrDid.includes("did:")) {
7077 did = handleOrDid;
7178 } else {
···7380 if (!actor) return [];
7481 did = actor.did;
7582 }
8383+7684 const { items: galleries } = ctx.indexService.getRecords<
7785 WithBffMeta<Gallery>
7886 >("social.grain.gallery", {
7987 where: [{ field: "did", equals: did }],
8088 orderBy: [{ field: "createdAt", direction: "desc" }],
8189 });
9090+8291 const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries);
8392 const creator = getActorProfile(did, ctx);
9393+8494 if (!creator) return [];
9595+8596 return galleries.map((gallery) =>
8697 galleryToView(gallery, creator, galleryPhotosMap.get(gallery.uri) ?? [])
8798 );
8899}
100100+101101+export function getActorGalleryFavs(handleOrDid: string, ctx: BffContext) {
102102+ let did: string;
103103+104104+ if (handleOrDid.includes("did:")) {
105105+ did = handleOrDid;
106106+ } else {
107107+ const actor = ctx.indexService.getActorByHandle(handleOrDid);
108108+ if (!actor) return [];
109109+ did = actor.did;
110110+ }
111111+112112+ const { items: favRecords } = ctx.indexService.getRecords<
113113+ WithBffMeta<Favorite>
114114+ >(
115115+ "social.grain.favorite",
116116+ {
117117+ where: [{ field: "did", equals: did }],
118118+ orderBy: [{ field: "createdAt", direction: "desc" }],
119119+ },
120120+ );
121121+122122+ if (!favRecords.length) return [];
123123+124124+ const galleryUris = favRecords.map((fav) => fav.subject);
125125+126126+ const { items: galleries } = ctx.indexService.getRecords<
127127+ WithBffMeta<Gallery>
128128+ >(
129129+ "social.grain.gallery",
130130+ {
131131+ where: [{ field: "uri", in: galleryUris }],
132132+ },
133133+ );
134134+135135+ // Map gallery uri to gallery object for fast lookup
136136+ const galleryMap = new Map(galleries.map((g) => [g.uri, g]));
137137+ const galleryPhotosMap = getGalleryItemsAndPhotos(ctx, galleries);
138138+ const creators = new Map<string, ReturnType<typeof getActorProfile>>();
139139+ const uniqueDids = Array.from(
140140+ new Set(galleries.map((gallery) => gallery.did)),
141141+ );
142142+143143+ const { items: profiles } = ctx.indexService.getRecords<
144144+ WithBffMeta<GrainProfile>
145145+ >(
146146+ "social.grain.actor.profile",
147147+ {
148148+ where: [{ field: "did", in: uniqueDids }],
149149+ },
150150+ );
151151+152152+ for (const profile of profiles) {
153153+ const handle = ctx.indexService.getActor(profile.did)?.handle ?? "";
154154+ creators.set(profile.did, profileToView(profile, handle));
155155+ }
156156+157157+ // Order galleries by the order of favRecords (favorited at)
158158+ return favRecords
159159+ .map((fav) => {
160160+ const gallery = galleryMap.get(fav.subject);
161161+ if (!gallery) return null;
162162+ const creator = creators.get(gallery.did);
163163+ if (!creator) return null;
164164+ return galleryToView(
165165+ gallery,
166166+ creator,
167167+ galleryPhotosMap.get(gallery.uri) ?? [],
168168+ );
169169+ })
170170+ .filter((g) => g !== null);
171171+}
172172+173173+export function getActorProfiles(
174174+ handleOrDid: string,
175175+ ctx: BffContext,
176176+): SocialNetwork[] {
177177+ let did: string;
178178+179179+ if (handleOrDid.includes("did:")) {
180180+ did = handleOrDid;
181181+ } else {
182182+ const actor = ctx.indexService.getActorByHandle(handleOrDid);
183183+ if (!actor) return [];
184184+ did = actor.did;
185185+ }
186186+187187+ const { items: grainProfiles } = ctx.indexService.getRecords<
188188+ WithBffMeta<GrainProfile>
189189+ >(
190190+ "social.grain.actor.profile",
191191+ {
192192+ where: {
193193+ AND: [
194194+ { field: "did", equals: did },
195195+ { field: "uri", contains: "self" },
196196+ ],
197197+ },
198198+ },
199199+ );
200200+201201+ const { items: tangledProfiles } = ctx.indexService.getRecords<
202202+ WithBffMeta<TangledProfile>
203203+ >(
204204+ "sh.tangled.actor.profile",
205205+ {
206206+ where: {
207207+ AND: [
208208+ { field: "did", equals: did },
209209+ { field: "uri", contains: "self" },
210210+ ],
211211+ },
212212+ },
213213+ );
214214+215215+ const { items: bskyProfiles } = ctx.indexService.getRecords<
216216+ WithBffMeta<BskyProfile>
217217+ >(
218218+ "app.bsky.actor.profile",
219219+ {
220220+ where: {
221221+ AND: [
222222+ { field: "did", equals: did },
223223+ { field: "uri", contains: "self" },
224224+ ],
225225+ },
226226+ },
227227+ );
228228+229229+ const profiles: SocialNetwork[] = [];
230230+ if (grainProfiles.length) profiles.push("grain");
231231+ if (bskyProfiles.length) profiles.push("bluesky");
232232+ if (tangledProfiles.length) profiles.push("tangled");
233233+ return profiles;
234234+}