diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 1ff9f6e9..ea6b143c 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -46,7 +46,8 @@ async function authMethods (user, args, { models, me }) { email: user.emailVerified && user.email, twitter: oauth.indexOf('twitter') >= 0, github: oauth.indexOf('github') >= 0, - nostr: !!user.nostrAuthPubkey + nostr: !!user.nostrAuthPubkey, + apiKey: user.apiKeyEnabled ? user.apiKey : null } } @@ -521,6 +522,26 @@ export default { return await models.user.findUnique({ where: { id: me.id } }) }, + generateApiKey: async (parent, { id }, { models, me }) => { + if (!me) { + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + } + + const user = await models.user.findUnique({ where: { id: me.id } }) + if (!user.apiKeyEnabled) { + throw new GraphQLError('you are not allowed to generate api keys', { extensions: { code: 'FORBIDDEN' } }) + } + + const [{ apiKey }] = await models.$queryRaw`UPDATE users SET "apiKey" = encode(gen_random_bytes(32), 'base64')::CHAR(32) WHERE id = ${me.id} RETURNING "apiKey"` + return apiKey + }, + deleteApiKey: async (parent, { id }, { models, me }) => { + if (!me) { + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + } + + return await models.user.update({ where: { id: me.id }, data: { apiKey: null } }) + }, unlinkAuth: async (parent, { authType }, { models, me }) => { if (!me) { throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index aa054913..e7145a16 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -36,6 +36,8 @@ export default gql` subscribeUserPosts(id: ID): User subscribeUserComments(id: ID): User toggleMute(id: ID): User + generateApiKey(id: ID!): String + deleteApiKey(id: ID!): User } type User { @@ -100,6 +102,7 @@ export default gql` github: Boolean! twitter: Boolean! email: String + apiKey: String } type UserPrivates { @@ -118,6 +121,7 @@ export default gql` tipPopover: Boolean! upvotePopover: Boolean! hasInvites: Boolean! + apiKeyEnabled: Boolean! """ mirrors SettingsInput diff --git a/components/discussion-form.js b/components/discussion-form.js index 0860b60e..97aa7a77 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -27,6 +27,7 @@ export function DiscussionForm ({ const schema = discussionSchema({ client, me, existingBoost: item?.boost }) // if Web Share Target API was used const shareTitle = router.query.title + const shareText = router.query.text ? decodeURI(router.query.text) : undefined const crossposter = useCrossposter() const toaster = useToast() @@ -88,7 +89,7 @@ export function DiscussionForm ({
{ - const session = await getServerSession(req, res, getAuthOptions(req)) + const apiKey = req.headers['x-api-key'] + let session + if (apiKey) { + const sessionFieldSelect = { name: true, id: true, email: true } + const user = await models.user.findUnique({ where: { apiKey }, select: { ...sessionFieldSelect, apiKeyEnabled: true } }) + if (user?.apiKeyEnabled) { + const { apiKeyEnabled, ...sessionFields } = user + session = { user: { ...sessionFields, apiKey: true } } + } + } else { + session = await getServerSession(req, res, getAuthOptions(req)) + } return { models, headers: req.headers, diff --git a/pages/settings/index.js b/pages/settings/index.js index 3c55df17..e4b7a5db 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -1,4 +1,4 @@ -import { Checkbox, Form, Input, SubmitButton, Select, VariableInput } from '../../components/form' +import { Checkbox, Form, Input, SubmitButton, Select, VariableInput, CopyInput } from '../../components/form' import Alert from 'react-bootstrap/Alert' import Button from 'react-bootstrap/Button' import InputGroup from 'react-bootstrap/InputGroup' @@ -26,6 +26,8 @@ import { useToast } from '../../components/toast' import { useLogger } from '../../components/logger' import { useMe } from '../../components/me' import { INVOICE_RETENTION_DAYS } from '../../lib/constants' +import { OverlayTrigger, Tooltip } from 'react-bootstrap' +import DeleteIcon from '../../svgs/delete-bin-line.svg' export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true }) @@ -493,7 +495,7 @@ export default function Settings ({ ssrData }) {
saturday newsletter
- {settings?.authMethods && } + {settings?.authMethods && }
@@ -606,7 +608,7 @@ function UnlinkObstacle ({ onClose, type, unlinkAuth }) { ) } -function AuthMethods ({ methods }) { +function AuthMethods ({ methods, apiKeyEnabled }) { const showModal = useShowModal() const router = useRouter() const toaster = useToast() @@ -642,7 +644,7 @@ function AuthMethods ({ methods }) { ) // sort to prevent hydration mismatch - const providers = Object.keys(methods).filter(k => k !== '__typename').sort() + const providers = Object.keys(methods).filter(k => k !== '__typename' && k !== 'apiKey').sort() const unlink = async type => { // if there's only one auth method left @@ -727,6 +729,7 @@ function AuthMethods ({ methods }) { ) } })} + ) } @@ -766,3 +769,148 @@ export function EmailLinkForm ({ callbackUrl }) { ) } + +export function ApiKey ({ enabled, apiKey }) { + const me = useMe() + const [generateApiKey] = useMutation( + gql` + mutation generateApiKey($id: ID!) { + generateApiKey(id: $id) + }`, + { + update (cache, { data: { generateApiKey } }) { + cache.modify({ + id: 'ROOT_QUERY', + fields: { + settings (existing) { + return { + ...existing, + privates: { + ...existing.privates, + apiKey: generateApiKey, + authMethods: { ...existing.privates.authMethods, apiKey: generateApiKey } + } + } + } + } + }) + } + } + ) + const [deleteApiKey] = useMutation( + gql` + mutation deleteApiKey($id: ID!) { + deleteApiKey(id: $id) { + id + } + }`, + { + update (cache, { data: { deleteApiKey } }) { + cache.modify({ + id: 'ROOT_QUERY', + fields: { + settings (existing) { + return { + ...existing, + privates: { + ...existing.privates, + authMethods: { ...existing.privates.authMethods, apiKey: null } + } + } + } + } + }) + } + } + ) + + const subject = '[API Key Request] ' + const body = + encodeURI(`**[API Key Request]** + +Hi, I would like to use API keys with the [Stacker News GraphQL API](/api/graphql) for the following reasons: + +... + +I expect to call the following GraphQL queries or mutations: + +... (you can leave empty if unknown) + +I estimate that I will call the GraphQL API this many times (rough estimate is fine): + +... (you can leave empty if unknown) +`) + const metaLink = encodeURI(`/~meta/post?type=discussion&title=${subject}&text=${body}`) + const mailto = `mailto:hello@stacker.news?subject=${subject}&body=${body}` + // link to DM with k00b on Telegram + const telegramLink = 'https://t.me/k00bideh' + // link to DM with ek on SimpleX + const simplexLink = 'https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2F6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE%3D%40smp10.simplex.im%2FxNnPk9DkTbQJ6NckWom9mi5vheo_VPLm%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAnFUiU0M8jS1JY34LxUoPr7mdJlFZwf3pFkjRrhprdQs%253D%26srv%3Drb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion' + + const disabled = !enabled + + return ( + <> +
api key
+
+ {apiKey && + <> + + } + request access to API keys in ~meta : <>} + trigger={['hover', 'focus']} + > +
+ {apiKey + ? { + await deleteApiKey({ variables: { id: me.id } }) + }} + /> + : ( + + )} +
+
+ +
    +
  • use API keys with our GraphQL API for authentication
  • +
  • you need to add the API key to the X-API-Key header of your requests
  • +
  • you can currently only generate API keys if we enabled it for your account
  • +
  • + you can{' '} + create a post in ~meta to request access + or reach out to us via +
      +
    • email
    • +
    • Telegram
    • +
    • SimpleX
    • +
    +
  • +
  • please include following information in your request: +
      +
    • your nym on SN
    • +
    • what you want to achieve with authenticated API access
    • +
    • which GraphQL queries or mutations you expect to call
    • +
    • your (rough) estimate how often you will call the GraphQL API
    • +
    +
  • +
+
+
+ + ) +} diff --git a/prisma/migrations/20240312111708_api_keys/migration.sql b/prisma/migrations/20240312111708_api_keys/migration.sql new file mode 100644 index 00000000..4ebb1eca --- /dev/null +++ b/prisma/migrations/20240312111708_api_keys/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - A unique constraint covering the columns `[apiKey]` on the table `users` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "apiKey" CHAR(32), +ADD COLUMN "apiKeyEnabled" BOOLEAN NOT NULL DEFAULT false; + +-- CreateIndex +CREATE UNIQUE INDEX "users.apikey_unique" ON "users"("apiKey"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6884165a..c5a19ab2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,6 +26,8 @@ model User { checkedNotesAt DateTime? foundNotesAt DateTime? pubkey String? @unique(map: "users.pubkey_unique") + apiKey String? @db.Char(32) @unique(map: "users.apikey_unique") + apiKeyEnabled Boolean @default(false) tipDefault Int @default(100) bioId Int? inviteId String? diff --git a/svgs/delete-bin-line.svg b/svgs/delete-bin-line.svg new file mode 100644 index 00000000..a71c4d8c --- /dev/null +++ b/svgs/delete-bin-line.svg @@ -0,0 +1 @@ + \ No newline at end of file