diff --git a/apps/golinks-v2/docs/self-hosting.md b/apps/golinks-v2/docs/self-hosting.md new file mode 100644 index 0000000..958a2f6 --- /dev/null +++ b/apps/golinks-v2/docs/self-hosting.md @@ -0,0 +1,97 @@ +# Running your own instance + +## Alternatives to this + +If you prefer not to use Cloudflare's developer platform for any reasons, you can technically + +## Prep work + +- Update the `account_id` by pointing to your own Cloudflare account ID. + +```diff +name = "golinks-next" +main = "src/index.tsx" +compatibility_date = "2024-07-12" +-account_id = "cf0bd808c6a294fd8c4d8f6d2cdeca05" ++account_id = "your-cloudflare-account-id-here" +``` + +* Create two KV namespaces for Slack bot tokens with one for both `wrangler dev` and stagingm another for production, and change `kv_namespaces[0].id` into the ID values of these created via CLI. See the diff after the wrangler commands on how you should replace them. + +```bash +yarn wrangler kv namespace create slack-bot-token --env staging +yarn wrangler kv namespace create slack-bot-token --env production +``` + +```diff +kv_namespaces = [ +- { binding = "slackBotTokens", id = "24a1f8bb9d434e97a490c43b74435e64" } ++ { binding = "slackBotTokens", id = "stg-env-namespace-id-here" } +] + +# snip... + +[env.staging] +kv_namespaces = [ +- { binding = "slackBotTokens", id = "24a1f8bb9d434e97a490c43b74435e64" } ++ { binding = "slackBotTokens", id = "stg-env-namespace-id-here" } +] + +# snip... + +[env.production] +kv_namespaces = [ +- { binding = "slackBotTokens", id = "21b6d6aaacbf4f04b52a66a9ed11fcd3" } ++ { binding = "slackBotTokens", id = "production-env-namespace-id-here" } +] +``` + +- For the D1 database, repeat but replace `kv namespace create` with `d1 create`. + +```bash +yarn wrangler d1 create golinks-db-preview +yarn wrangler d1 create golinks-db +``` + +```diff +d1_databases = [ +- { binding = "golinks", database_name = "golinks-db-preview", database_id = "0b415a64-cc61-4c2b-a7c8-2d223f18e996", migrations_dir = "migrations" }, +- { binding = "golinks", database_name = "golinks-db-preview", database_id = "0b415a64-cc61-4c2b-a7c8-2d223f18e996", migrations_dir = "migrations" }, +] + +[env.staging] +d1_databases = [ +- { binding = "golinks", database_name = "golinks-db-preview", database_id = "0b415a64-cc61-4c2b-a7c8-2d223f18e996", migrations_dir = "migrations" }, ++ { binding = "golinks", database_name = "golinks-db-preview", database_id = "stg-d1-id-here", migrations_dir = "migrations" }, +] + +[env.production] +d1_databases = [ +- { binding = "golinks", database_name = "golinks-db-preview", database_id = "0b415a64-cc61-4c2b-a7c8-2d223f18e996", migrations_dir = "migrations" }, ++ { binding = "golinks", database_name = "golinks-db-preview", database_id = "prod-d1-id-here", migrations_dir = "migrations" }, +] +``` + +## Secret names and variables + +- `BASE_URL` - Used as a prefix for URLs, especially in OAuth callback URLs. +- `GITHUB_OAUTH_ID` and `GITHUB_OAUTH_SECRET` - GitHub OAuth client ID and secret for token generation service +- `SLACK_OAUTH_ID` and `SLACK_OAUTH_SECRET` - Slack app OAuth client ID and secret for app installs to work, stored on KV via `@slack/oauth`'s `installationStore`. + +### Setup + +- Add the secrets first. + +```bash +# first, rotate the keys by resetting dotenv files +rm .env.staging .env.production && touch .env.staging .env.production + +# add them via dotenvx +yarn dotenvx set -f .env.production -- SLACK_OAUTH_ID +yarn dotenvx set -f .env.production -- SLACK_OAUTH_SECRET +yarn dotenvx set -f .env.production -- GITHUB_OAUTH_ID +yarn dotenvx set -f .env.production -- GITHUB_OAUTH_SECRET + +# ..and then pipe to wrangler secret put command +yarn dotenvx get -f .env.production -- SLACK_OAUTH_ID | yarn wrangler secret put SLACK_OAUTH_ID --env production +``` diff --git a/apps/golinks-v2/scripts/db/initialize-db.sql b/apps/golinks-v2/scripts/db/initialize-db.sql new file mode 100644 index 0000000..b3287d0 --- /dev/null +++ b/apps/golinks-v2/scripts/db/initialize-db.sql @@ -0,0 +1,112 @@ +/* + +The following were generated by Prisma using the following command in Prisma CLI: + + yarn workspace @andreijiroh-dev/golinks-rewrite prisma migrate diff \ + --from-empty --to-schema-datamodel ./prisma/schema.prisma ] + --script --output ./scripts/db/initialize-db.sql + +To initalize your database locally, run the following wrangler command: + + yarn workspace @andeijiroh-dev/golinks-rewrite wrangler d1 execute \ + golinks --local --file ./scripts/dv/initialize-db.sql + +DO NOT RUN THIS SQL SCRIPT IN PRODUCTION! Instead, run yarn migrate:prod in the +context of this app (@andreijiroh-dev/golinks-rewrite subpackage) to initalize +your database and update schemas. + +*/ + +-- CreateTable +CREATE TABLE "GoLink" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "slug" TEXT NOT NULL, + "targetUrl" TEXT NOT NULL DEFAULT 'https://wiki.andreijiroh.xyz/golinks', + "is_active" BOOLEAN NOT NULL DEFAULT true, + "deactivation_reason" TEXT, + "created_on" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_on" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "DiscordInviteLink" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "slug" TEXT NOT NULL, + "inviteCode" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "nsfw" BOOLEAN NOT NULL DEFAULT false, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "deactivation_reason" TEXT, + "created_on" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_on" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "WikiLinks" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "slug" TEXT NOT NULL, + "targetUrl" TEXT NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "deactivation_reason" TEXT, + "created_on" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_on" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "SlackBotToken" ( + "teamId" TEXT NOT NULL PRIMARY KEY, + "enterprise_install" BOOLEAN NOT NULL DEFAULT false, + "token" TEXT NOT NULL, + "created_on" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_on" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "GitHubOAuthChallenge" ( + "id" TEXT NOT NULL PRIMARY KEY, + "challenge" TEXT NOT NULL, + "code" TEXT, + "metadata" TEXT NOT NULL, + "username" TEXT, + "userId" TEXT, + "created_on" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_on" DATETIME NOT NULL, + "expires_on" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "ApiToken" ( + "id" TEXT NOT NULL PRIMARY KEY, + "token" TEXT NOT NULL, + "userId" TEXT NOT NULL, + CONSTRAINT "ApiToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "username" TEXT NOT NULL, + "email" TEXT, + "site_admin" BOOLEAN NOT NULL DEFAULT false, + "created_on" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_on" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "GoLink_slug_key" ON "GoLink"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "DiscordInviteLink_slug_key" ON "DiscordInviteLink"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "WikiLinks_slug_key" ON "WikiLinks"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "SlackBotToken_teamId_key" ON "SlackBotToken"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiToken_token_key" ON "ApiToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/apps/golinks-v2/scripts/db/reset-db.sql b/apps/golinks-v2/scripts/db/reset-db.sql new file mode 100644 index 0000000..815a449 --- /dev/null +++ b/apps/golinks-v2/scripts/db/reset-db.sql @@ -0,0 +1,13 @@ +/* + +This SQL script deletes literally everything from your database. +Please tread lightly, especially you don't want to run this against +production. + +*/ +DELETE FROM GoLink; +DELETE FROM WikiLinks; +DELETE FROM DiscordInviteLink; +DELETE FROM ApiToken; +DELETE FROM User; +DELETE FROM SlackBotToken; diff --git a/apps/golinks-v2/src/api/slack.ts b/apps/golinks-v2/src/api/slack.ts index 18827b7..4f5fd63 100644 --- a/apps/golinks-v2/src/api/slack.ts +++ b/apps/golinks-v2/src/api/slack.ts @@ -10,7 +10,7 @@ import { SignJWT } from "jose"; import URLSearchParams from "url-search-params"; import { Installation, InstallationQuery } from "@slack/oauth"; import { Bool, OpenAPIRoute, Str } from "chanfana"; -import { adminApiKey } from "lib/constants"; +import { adminApiKey, userApiKey } from "lib/constants"; import { z } from "zod"; import { EnvBindings } from "types"; import { addGoLink } from "lib/db"; @@ -568,7 +568,7 @@ export class debugApiGetSlackBotToken extends OpenAPIRoute { }, security: [ { - adminApiKey: [], + userApiKey: [], }, ], }; @@ -611,7 +611,7 @@ export class debugApiTestSlackBotToken extends OpenAPIRoute { }, security: [ { - adminApiKey: [], + userApiKey: [], }, ], }; diff --git a/apps/golinks-v2/wrangler.toml b/apps/golinks-v2/wrangler.toml index 04cf6cf..14219fe 100644 --- a/apps/golinks-v2/wrangler.toml +++ b/apps/golinks-v2/wrangler.toml @@ -9,7 +9,7 @@ name = "golinks-next" main = "src/index.tsx" -compatibility_date = "2024-07-12" # TODO: Update this once a month. +compatibility_date = "2024-07-12" account_id = "cf0bd808c6a294fd8c4d8f6d2cdeca05" placement = { mode = "smart" } diff --git a/apps/helpMessageBot.js b/apps/helpMessageBot.js new file mode 100644 index 0000000..28ee2c8 --- /dev/null +++ b/apps/helpMessageBot.js @@ -0,0 +1,83 @@ +function helpMessage() { + const templateJson = { + blocks: [ + { + type: "header", + text: { + type: "plain_text", + text: "slack.go.andreijiroh.xyz - @ajhalili2006's golinks service in Slack", + emoji: true, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: "I am the bot that uses to manage his golinks in Slack, although you can use me as a link shortener and to request custom golinks for approval.", + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: "Need to shorten a link, add a Discord or wikilink? Submit a request and you'll be notified via DMs if it's added.", + }, + accessory: { + type: "button", + text: { + type: "plain_text", + text: "Request a link", + emoji: true, + }, + value: "request-link", + action_id: "golinks-bot-action", + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: "Want to use me programmatically? You can request a API token using your GitHub account through the OAuth prompt.", + }, + accessory: { + type: "button", + text: { + type: "plain_text", + text: "Sign in to get token", + emoji: true, + }, + value: "tbd", + action_id: "github-auth-challenge", + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: "Want to see the API docs and experiment with it? You can take a sneak peek.", + }, + accessory: { + type: "button", + text: { + type: "plain_text", + text: "Open API docs", + emoji: true, + }, + url: `https://go.andreijiroh.xyz/api/docs`, + }, + }, + { + type: "context", + elements: [ + { + text: "If something go wrong, or ping @ajhalili2006 on the fediverse (or here in the Slack if he's here).", + type: "mrkdwn", + }, + ], + }, + ], + }; + return JSON.stringify(templateJson); +} + +console.log(helpMessage()) diff --git a/apps/oblong-oauth-helper/.vscode/settings.json b/apps/oblong-oauth-helper/.vscode/settings.json new file mode 100644 index 0000000..963e37d --- /dev/null +++ b/apps/oblong-oauth-helper/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "deno.enablePaths": [ + "./" + ], + "deno.enable": true, + "editor.inlayHints.enabled": "off" +} \ No newline at end of file diff --git a/apps/oblong-oauth-helper/README.md b/apps/oblong-oauth-helper/README.md new file mode 100644 index 0000000..c23b663 --- /dev/null +++ b/apps/oblong-oauth-helper/README.md @@ -0,0 +1,7 @@ +# OAuth helper server for `obl.ong` + +## Required variables + +* `OBLONG_API_BASE_URL` - The base URL in form of `https://your.domain.tld` +* `OBLONG_API_CLIENT_ID` and `OBLONG_API_CLIENT_SECRET` - +* `ISSUE_TRACKER_NEW_URL` - diff --git a/apps/oblong-oauth-helper/deno.json b/apps/oblong-oauth-helper/deno.json new file mode 100644 index 0000000..2e9b45e --- /dev/null +++ b/apps/oblong-oauth-helper/deno.json @@ -0,0 +1,18 @@ +{ + "imports": { + "hono": "jsr:@hono/hono@^4.5.5", + "uuid": "jsr:@std/uuid" + }, + "tasks": { + "start": "deno run --allow-net main.ts", + "deploy:prod": "deno run -A jsr:@deno/deployctl deploy --prod --project=oblong-oauth-helper --entrypoint=main.ts" + }, + "deploy": { + "project": "dfafc8d0-0841-4651-9003-5ef2249136e0", + "exclude": [ + "**/node_modules" + ], + "include": [], + "entrypoint": "main.ts" + } +} diff --git a/apps/oblong-oauth-helper/deno.lock b/apps/oblong-oauth-helper/deno.lock new file mode 100644 index 0000000..76adb3e --- /dev/null +++ b/apps/oblong-oauth-helper/deno.lock @@ -0,0 +1,30 @@ +{ + "version": "3", + "packages": { + "specifiers": { + "jsr:@hono/hono@^4.5.5": "jsr:@hono/hono@4.5.5", + "jsr:@std/bytes@^1.0.1-rc.3": "jsr:@std/bytes@1.0.2", + "jsr:@std/crypto@^1.0.0-rc.1": "jsr:@std/crypto@1.0.2", + "jsr:@std/uuid": "jsr:@std/uuid@1.0.0" + }, + "jsr": { + "@hono/hono@4.5.5": { + "integrity": "e5a63b5f535475cd80974b65fed23a138d0cbb91fe1cc9a17a7c7278e835c308" + }, + "@std/bytes@1.0.2": { + "integrity": "fbdee322bbd8c599a6af186a1603b3355e59a5fb1baa139f8f4c3c9a1b3e3d57" + }, + "@std/crypto@1.0.2": { + "integrity": "bb515dbcd4d67aacf0ffece643a434811ea06e5b544f03c33ba58552fbdb87c7" + }, + "@std/uuid@1.0.0": { + "integrity": "32595ccd31756728c9110cb98e25000e72b8d63151b7211034ea081761ed2104", + "dependencies": [ + "jsr:@std/bytes@^1.0.1-rc.3", + "jsr:@std/crypto@^1.0.0-rc.1" + ] + } + } + }, + "remote": {} +} diff --git a/apps/oblong-oauth-helper/lib.ts b/apps/oblong-oauth-helper/lib.ts new file mode 100644 index 0000000..f6be392 --- /dev/null +++ b/apps/oblong-oauth-helper/lib.ts @@ -0,0 +1,88 @@ +type resultingData = { + access_token: string, + token_type: "Bearer", + expires_in: 7200, + scope: string, + created_at: number, + id_token?: string, + error: string, + error_description: string +} + +export const clientId = Deno.env.get("OBLONG_API_CLIENT_ID"); +export const clientSecret = Deno.env.get("OBLONG_API_CLIENT_SECRET"); +export const redirect_uri = `${Deno.env.get("OBLONG_API_BASE_URL")}/auth/callback`; + +export const home = `\ +Howdy, this is a Hono server for helping obl.ong domain holders to generate their +own tokens for API access, hosted on Deno Deploy by ~ajhalili2006 (ajhalili2006.obl.ong). + +To get started, navigate to ${Deno.env.get("OBLONG_API_BASE_URL")}/auth, +approve and copy the API token after the auth code exchange. + +This server is open-source under the MPL-2.0 license at +https://github.com/andreijiroh-dev/api-servers/blob/main/apps/oblong-oauth-helper. +Consider supporting ~ajhalili2006 at https://github.com/sponsors/ajhalili2006 or +https://sponsors.andreijiroh.xyz to keep this service maintained. +`; + +export const authCode = (code: string) => `\ +We've received the authenication code, but it is intended for another OAuth client \ +on obl.ong admin API. Copy the following code into the application requesting it to \ +exchange for an access token: + + ${code} + +Do not share it with anyone, even to the domain holder behind libdns.obl.ong. +` + +export const accessToken = (token: string) => `\ +Here's your access token for obl.ong admin API, which is valid for 2 hours: + + ${token} + +Do not share it with anyone, even to the domain holder behind libdns.obl.ong. +Keep the access token in a safe place, since you'll be no longer able to +retrieve this again once you navigate away from or refresh this page. +` + +export const handleError = (error: string) => `\ +Something went wrong while exchanging your authenication code for a token. +The error code from the obl.ong backend was ${error}. + +Not expected to see this? Please report this as bug at +${Deno.env.get("OBLONG_API_BASE_URL")}/feedback and we'll looking +into it. +` + +export async function exchangeCodeForToken(code: string, state: string) { + + const data = await fetch("https://admin.obl.ong/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri, + state, + grant_type: "authorization_code", + }), + }) + const resultJson: resultingData = await data.json() + + if (resultJson.access_token) { + return { + ok: true, + token: resultJson.access_token + }; + } else { + return { + ok: false, + token: null, + error: resultJson.error + } + } +} diff --git a/apps/oblong-oauth-helper/main.ts b/apps/oblong-oauth-helper/main.ts new file mode 100644 index 0000000..bba5f1f --- /dev/null +++ b/apps/oblong-oauth-helper/main.ts @@ -0,0 +1,60 @@ +import { Hono } from 'hono' +import { authCode, clientId, handleError, home, redirect_uri } from "./lib.ts"; +import { exchangeCodeForToken } from "./lib.ts"; +import { accessToken } from "./lib.ts"; +import * as uuid from "uuid" + +const app = new Hono() +const baseUrl = new URL("https://admin.obl.ong/oauth/authorize"); +const scopes = "domains domains_records domains_records_write user"; +const paramOps = baseUrl.searchParams + +app.get("/", (c) => { + return c.text(home) +}) + +app.get("/feedback", (c) => { + return c.redirect(Deno.env.get("ISSUE_TRACKER_NEW_URL")) +}) + +app.get("/auth", (c) => { + const {state} = c.req.query() + paramOps.set("client_id", clientId); + paramOps.set("redirect_uri", redirect_uri); + paramOps.set("scope", scopes); + paramOps.set("response_type", "code"); + + if (state) { + paramOps.set("state", state); + } else { + paramOps.set("state", crypto.randomUUID()); + } + + return c.redirect(baseUrl.toString()) +}) + +app.get("/auth/callback", async(c) => { + const { code, state } = c.req.query() + + if (!code || !state) { + return c.text("Missing authenication code or state", 400) + } + + if (state == "cli-access") { + return c.text(authCode(code), 200) + } + + if (uuid.v4.validate(state) == false) { + return c.text(handleError("state_not_uuidV4"), 400) + } + + const { token, error } = await exchangeCodeForToken(code, state) + + if (token) { + return c.text(accessToken(token)) + } else { + return c.text(handleError(error), 400) + } +}) + +Deno.serve(app.fetch)