diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..72c3ea5 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,40 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "request": "launch", + "name": "Reinitialize database", + "type": "node", + "runtimeExecutable": "/usr/bin/deno", + "runtimeArgs": [ + "run", + "--no-prompt", + "--allow-all", + "--inspect-wait" + ], + "program": "${workspaceFolder}/src/entry/dbInit.ts", + "cwd": "${workspaceFolder}", + "envFile": "${workspaceFolder}/local.env", + "attachSimplePort": 9229 + }, + { + "request": "launch", + "name": "Dev Server", + "type": "node", + "runtimeExecutable": "/usr/bin/deno", + "runtimeArgs": [ + "run", + "--no-prompt", + "--allow-all", + "--inspect-wait" + ], + "program": "${workspaceFolder}/src/entry/server.ts", + "cwd": "${workspaceFolder}", + "envFile": "${workspaceFolder}/local.env", + "attachSimplePort": 9229 + } + ] +} \ No newline at end of file diff --git a/deno.json b/deno.json index 5944e6e..6b0df48 100644 --- a/deno.json +++ b/deno.json @@ -1,13 +1,14 @@ { "imports": { "@@npm/ioredis": "npm:ioredis@^5.4.1", + "@@x/escape": "https://deno.land/x/escape@1.3.0/mod.ts", "@fedify/fedify": "jsr:@fedify/fedify@^1.0.2", - "@fedify/redis": "jsr:@fedify/redis@^0.3.0", + "@fedify/postgres": "jsr:@fedify/postgres@^0.1.0", "@hongminhee/x-forwarded-fetch": "jsr:@hongminhee/x-forwarded-fetch@^0.2.0", "@logtape/logtape": "jsr:@logtape/logtape@^0.6.3", "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", "@std/assert": "jsr:@std/assert@1", - "@@x/escape": "https://deno.land/x/escape@1.3.0/mod.ts" + "@@npm/postgres": "npm:postgres@^3.4.4" }, "unstable": [ "temporal" diff --git a/deno.lock b/deno.lock index 16ac9f1..901372b 100644 --- a/deno.lock +++ b/deno.lock @@ -4,7 +4,7 @@ "specifiers": { "jsr:@fedify/fedify@^1.0.0": "jsr:@fedify/fedify@1.0.2", "jsr:@fedify/fedify@^1.0.2": "jsr:@fedify/fedify@1.0.2", - "jsr:@fedify/redis@^0.3.0": "jsr:@fedify/redis@0.3.0", + "jsr:@fedify/postgres@^0.1.0": "jsr:@fedify/postgres@0.1.0", "jsr:@hongminhee/x-forwarded-fetch@^0.2.0": "jsr:@hongminhee/x-forwarded-fetch@0.2.0", "jsr:@hugoalh/http-header-link@^1.0.2": "jsr:@hugoalh/http-header-link@1.0.2", "jsr:@hugoalh/is-string-singleline@1.0.2": "jsr:@hugoalh/is-string-singleline@1.0.2", @@ -20,12 +20,14 @@ "npm:@phensley/language-tag@^1.9.0": "npm:@phensley/language-tag@1.9.0", "npm:@types/node": "npm:@types/node@18.16.19", "npm:asn1js@^3.0.5": "npm:asn1js@3.0.5", + "npm:graffle@next": "npm:graffle@8.0.0-next.68_@opentelemetry+api@1.9.0_graphql@16.9.0", "npm:ioredis@^5.4.1": "npm:ioredis@5.4.1", "npm:json-canon@^1.0.1": "npm:json-canon@1.0.1", "npm:jsonld@^8.3.2": "npm:jsonld@8.3.2", "npm:multibase@^4.0.6": "npm:multibase@4.0.6", "npm:multicodec@^3.2.1": "npm:multicodec@3.2.1", "npm:pkijs@^3.2.4": "npm:pkijs@3.2.4", + "npm:postgres@^3.4.4": "npm:postgres@3.4.4", "npm:uri-template-router@^0.0.16": "npm:uri-template-router@0.0.16", "npm:url-template@^3.1.1": "npm:url-template@3.1.1" }, @@ -50,12 +52,11 @@ "npm:url-template@^3.1.1" ] }, - "@fedify/redis@0.3.0": { - "integrity": "48068af7ad24d4f6c6935d6f869659faeb92281a9e796921f9ba7d94e74f8cfc", + "@fedify/postgres@0.1.0": { + "integrity": "350e3e535372d84acebe7392ae98495e388f30d75165d52c6d32a8dc6e940b80", "dependencies": [ "jsr:@fedify/fedify@^1.0.0", - "jsr:@logtape/logtape@^0.6.3", - "npm:ioredis@^5.4.1" + "npm:postgres@^3.4.4" ] }, "@hongminhee/x-forwarded-fetch@0.2.0": { @@ -108,10 +109,38 @@ "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", "dependencies": {} }, + "@graphql-typed-document-node/core@3.2.0_graphql@16.9.0": { + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "dependencies": { + "graphql": "graphql@16.9.0" + } + }, "@ioredis/commands@1.2.0": { "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", "dependencies": {} }, + "@molt/command@0.9.0": { + "integrity": "sha512-1JI8dAlpqlZoXyKWVQggX7geFNPxBpocHIXQCsnxDjKy+3WX4SGyZVJXuLlqRRrX7FmQCuuMAfx642ovXmPA9g==", + "dependencies": { + "@molt/types": "@molt/types@0.2.0", + "alge": "alge@0.8.1", + "chalk": "chalk@5.3.0", + "lodash.camelcase": "lodash.camelcase@4.3.0", + "lodash.snakecase": "lodash.snakecase@4.1.1", + "readline-sync": "readline-sync@1.4.10", + "string-length": "string-length@6.0.0", + "strip-ansi": "strip-ansi@7.1.0", + "ts-toolbelt": "ts-toolbelt@9.6.0", + "type-fest": "type-fest@4.26.1", + "zod": "zod@3.23.8" + } + }, + "@molt/types@0.2.0": { + "integrity": "sha512-p6ChnEZDGjg9PYPec9BK6Yp5/DdSrYQvXTBAtgrnqX6N36cZy37ql1c8Tc5LclfIYBNG7EZp8NBcRTYJwyi84g==", + "dependencies": { + "ts-toolbelt": "ts-toolbelt@9.6.0" + } + }, "@multiformats/base-x@4.0.1": { "integrity": "sha512-eMk0b9ReBbV23xXU693TAIrLyeO5iTgBZGSJfpqriG8UkYvr/hC9u9pyMlAakDNHWmbhMZCDs6KQO0jzKD8OTw==", "dependencies": {} @@ -140,6 +169,19 @@ "event-target-shim": "event-target-shim@5.0.1" } }, + "alge@0.8.1": { + "integrity": "sha512-kiV9nTt+XIauAXsowVygDxMZLplZxDWt0W8plE/nB32/V2ziM/P/TxDbSVK7FYIUt2Xo16h3/htDh199LNPCKQ==", + "dependencies": { + "lodash.ismatch": "lodash.ismatch@4.4.0", + "remeda": "remeda@1.61.0", + "ts-toolbelt": "ts-toolbelt@9.6.0", + "zod": "zod@3.23.8" + } + }, + "ansi-regex@6.1.0": { + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dependencies": {} + }, "asn1js@3.0.5": { "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", "dependencies": { @@ -156,6 +198,10 @@ "integrity": "sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A==", "dependencies": {} }, + "chalk@5.3.0": { + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dependencies": {} + }, "cluster-key-slot@1.1.2": { "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", "dependencies": {} @@ -191,6 +237,20 @@ "fetch-blob": "fetch-blob@3.2.0" } }, + "graffle@8.0.0-next.68_@opentelemetry+api@1.9.0_graphql@16.9.0": { + "integrity": "sha512-oaigY1yEX48cUSiFJKtigjGw4ooA7JtLwafSUQuuAgbl0qtnaSmAGrfSRpb9RhxF0y/HEzJNEMYUaea085cn8w==", + "dependencies": { + "@graphql-typed-document-node/core": "@graphql-typed-document-node/core@3.2.0_graphql@16.9.0", + "@molt/command": "@molt/command@0.9.0", + "@opentelemetry/api": "@opentelemetry/api@1.9.0", + "graphql": "graphql@16.9.0", + "is-plain-obj": "is-plain-obj@4.1.0" + } + }, + "graphql@16.9.0": { + "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", + "dependencies": {} + }, "ioredis@5.4.1": { "integrity": "sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==", "dependencies": { @@ -205,6 +265,10 @@ "standard-as-callback": "standard-as-callback@2.1.0" } }, + "is-plain-obj@4.1.0": { + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dependencies": {} + }, "json-canon@1.0.1": { "integrity": "sha512-PQcj4PFOTAQxE8PgoQ4KrM0DcKWZd7S3ELOON8rmysl9I8JuFMgxu1H9v+oZsTPjjkpeS3IHPwLjr7d+gKygnw==", "dependencies": {} @@ -230,6 +294,10 @@ "integrity": "sha512-CasD9OCEQSFIam2U8efFK81Yeg8vNMTBUqtMOHlrcWQHqUX3HeCl9Dr31u4toV7emlH8Mymk5+9p0lL6mKb/Xw==", "dependencies": {} }, + "lodash.camelcase@4.3.0": { + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dependencies": {} + }, "lodash.defaults@4.2.0": { "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dependencies": {} @@ -238,6 +306,14 @@ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", "dependencies": {} }, + "lodash.ismatch@4.4.0": { + "integrity": "sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==", + "dependencies": {} + }, + "lodash.snakecase@4.1.1": { + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "dependencies": {} + }, "lru-cache@6.0.0": { "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dependencies": { @@ -288,6 +364,10 @@ "tslib": "tslib@2.7.0" } }, + "postgres@3.4.4": { + "integrity": "sha512-IbyN+9KslkqcXa8AO9fxpk97PA4pzewvpi2B3Dwy9u4zpV32QicaEdgmF3eSQUzdRk7ttDHQejNgAEr4XoeH4A==", + "dependencies": {} + }, "pvtsutils@1.3.5": { "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", "dependencies": { @@ -304,6 +384,10 @@ "setimmediate": "setimmediate@1.0.5" } }, + "readline-sync@1.4.10": { + "integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==", + "dependencies": {} + }, "redis-errors@1.2.0": { "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", "dependencies": {} @@ -314,6 +398,10 @@ "redis-errors": "redis-errors@1.2.0" } }, + "remeda@1.61.0": { + "integrity": "sha512-caKfSz9rDeSKBQQnlJnVW3mbVdFgxgGWQKq1XlFokqjf+hQD5gxutLGTTY2A/x24UxVyJe9gH5fAkFI63ULw4A==", + "dependencies": {} + }, "setimmediate@1.0.5": { "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "dependencies": {} @@ -322,10 +410,30 @@ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "dependencies": {} }, + "string-length@6.0.0": { + "integrity": "sha512-1U361pxZHEQ+FeSjzqRpV+cu2vTzYeWeafXFLykiFlv4Vc0n3njgU8HrMbyik5uwm77naWMuVG8fhEF+Ovb1Kg==", + "dependencies": { + "strip-ansi": "strip-ansi@7.1.0" + } + }, + "strip-ansi@7.1.0": { + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "ansi-regex@6.1.0" + } + }, + "ts-toolbelt@9.6.0": { + "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==", + "dependencies": {} + }, "tslib@2.7.0": { "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", "dependencies": {} }, + "type-fest@4.26.1": { + "integrity": "sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==", + "dependencies": {} + }, "uint8arrays@3.1.1": { "integrity": "sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==", "dependencies": { @@ -357,6 +465,10 @@ "yallist@4.0.0": { "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dependencies": {} + }, + "zod@3.23.8": { + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "dependencies": {} } } }, @@ -508,12 +620,13 @@ "workspace": { "dependencies": [ "jsr:@fedify/fedify@^1.0.2", - "jsr:@fedify/redis@^0.3.0", + "jsr:@fedify/postgres@^0.1.0", "jsr:@hongminhee/x-forwarded-fetch@^0.2.0", "jsr:@logtape/logtape@^0.6.3", "jsr:@std/assert@1", "npm:@opentelemetry/api@^1.9.0", - "npm:ioredis@^5.4.1" + "npm:ioredis@^5.4.1", + "npm:postgres@^3.4.4" ] } } diff --git a/src/database/index.ts b/src/database/index.ts new file mode 100644 index 0000000..07053fd --- /dev/null +++ b/src/database/index.ts @@ -0,0 +1,175 @@ +import Postgres from "@@npm/postgres" +import { getLogger } from "@logtape/logtape" +import { exportJwk, generateCryptoKeyPair, KvStore, MessageQueue } from "@fedify/fedify" +import { PostgresMessageQueue } from "@fedify/postgres" +import { PostgresKvStore } from "@fedify/postgres/kv" + + +const l = getLogger(["dotino-veloce", "database", "postgres"]) + + +export type DbActor = { + handle: string, + public_rsa: JsonWebKey, + public_ed: JsonWebKey, + private_rsa: JsonWebKey, + private_ed: JsonWebKey, +} + + +export class Database { + sql: Postgres.Sql + + constructor(connString: string) { + l.info`Creating Postgres object with string: ${connString}` + this.sql = Postgres(connString) + } + + static fromEnv() { + l.debug`Getting connection string from environment variable DOTINO_POSTGRES_STRING...` + const connString = Deno.env.get("DOTINO_POSTGRES_STRING") + if(!connString) { + l.error`DOTINO_POSTGRES_STRING is unset.` + throw new Error("DOTINO_POSTGRES_STRING is unset.") + } + + return new Database(connString) + } + + useAsKvStore(): KvStore { + l.info`Creating Postgres key-value store...` + return new PostgresKvStore(this.sql, {}) + } + + useAsMessageQueue(): MessageQueue { + l.info`Creating Postgres message queue...` + return new PostgresMessageQueue(this.sql, {}) + } + + async #doQueryFile(path: string, variables: Postgres.ParameterOrJSON[] = []): Promise> { + try { + l.debug`Running query at ${path}...` + var results = await this.sql.file(path, variables, undefined) + } + catch (e) { + l.error`Query at ${path} errored out with: ${e}` + throw e + } + l.debug`Query was successful, results are: ${results}` + return results + } + + async reinitializeDev() { + l.fatal`!!! Re-initializing database from scratch for development !!!` + + l.info`01/XX Dropping public schema from database...` + await this.#doQueryFile("src/database/init/01-drop-schema.sql") + + l.info`02/XX Recreating public schema...` + await this.#doQueryFile("src/database/init/02-create-schema.sql") + + l.info`03/XX Creating actor table...` + await this.#doQueryFile("src/database/init/03-create-actor.sql") + + l.info`04/XX Creating service actor...` + l.debug`Creating RSA key pair...` + const {publicKey: publicRsa, privateKey: privateRsa} = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5") + l.debug`Creating elliptic curve key pair...` + const {publicKey: publicEd, privateKey: privateEd} = await generateCryptoKeyPair("Ed25519") + l.debug`Exporting to JWK format...` + const publicRsaJwk = await exportJwk(publicRsa) + const publicEdJwk = await exportJwk(publicEd) + const privateRsaJwk = await exportJwk(privateRsa) + const privateEdJwk = await exportJwk(privateEd) + l.debug`Running SQL query...` + await this.#doQueryFile( + "src/database/init/04-insert-actor-app.sql", + [ + publicRsaJwk, + publicEdJwk, + privateRsaJwk, + privateEdJwk, + ] + ) + + l.info`05/XX Creating follow table...` + await this.#doQueryFile("src/database/init/05-create-follow.sql") + + l.info("Done!") + } + + async getActorApp(): Promise { + l.info`Getting app actor entity...` + + const results = await this.#doQueryFile("src/database/query/get-actor_app.sql") + + if(results.count == 0) { + l.error`No app actor entity exists.` + throw new Error("No app actor entity exists.") + } + else if(results.count > 1) { + l.error`Multiple app actor entities exist.` + throw new Error("Multiple app actor entities exist.") + } + + l.debug`Attempting to get only result...` + const result = results[0] + l.debug`Filtered down to result: ${result}` + + return result + } + + async getActorPlayer(steamId: number): Promise { + l.info`Getting player actor entity with id: ${steamId}` + + const results = await this.#doQueryFile("src/database/query/get-actor_player.sql", [steamId]) + + if(results.count == 0) { + l.debug`No such player actor entity exists.` + return null + } + else if(results.count > 1) { + l.error`Multiple player actor entities having the same SteamID exist.` + throw new Error("Multiple player actor entities having the same SteamID exist.") + } + + l.debug`Attempting to get only result...` + const result = results[0] + l.debug`Filtered down to result: ${result}` + + return result + } + + async getActorGuild(guildId: number): Promise { + l.info`Getting guild actor entity with id: ${guildId}` + + const results = await this.#doQueryFile("src/database/query/get-actor_guild.sql", [guildId]) + + if(results.count == 0) { + l.debug`No such guild actor entity exists.` + return null + } + else if(results.count > 1) { + l.error`Multiple guild actor entities having the same Guild ID exist.` + throw new Error("Multiple guild actor entities having the same Guild ID exist.") + } + + l.debug`Attempting to get only result...` + const result = results[0] + l.debug`Filtered down to result: ${result}` + + return result + } + + async putFollow(follower: string, followed: string): Promise { + l.info`Putting follow: from ${follower} to ${followed}` + + await this.#doQueryFile("src/database/query/put-follow.sql", [follower, followed]) + } + + async deleteFollow(follower: string, followed: string): Promise { + l.info`Deleting follow: from ${follower} to ${followed}` + + await this.#doQueryFile("src/database/query/delete-follow.sql", [follower, followed]) + } +} diff --git a/src/database/init/01-drop-schema.sql b/src/database/init/01-drop-schema.sql new file mode 100644 index 0000000..cae5c3d --- /dev/null +++ b/src/database/init/01-drop-schema.sql @@ -0,0 +1 @@ +DROP SCHEMA public CASCADE; diff --git a/src/database/init/02-create-schema.sql b/src/database/init/02-create-schema.sql new file mode 100644 index 0000000..32e23a6 --- /dev/null +++ b/src/database/init/02-create-schema.sql @@ -0,0 +1 @@ +CREATE SCHEMA public; diff --git a/src/database/init/03-create-actor.sql b/src/database/init/03-create-actor.sql new file mode 100644 index 0000000..1787ec0 --- /dev/null +++ b/src/database/init/03-create-actor.sql @@ -0,0 +1,8 @@ +CREATE TABLE actor ( + handle VARCHAR PRIMARY KEY, + + public_rsa JSON, + public_ed JSON, + private_rsa JSON, + private_ed JSON +); diff --git a/src/database/init/04-insert-actor-app.sql b/src/database/init/04-insert-actor-app.sql new file mode 100644 index 0000000..5377df2 --- /dev/null +++ b/src/database/init/04-insert-actor-app.sql @@ -0,0 +1,13 @@ +INSERT INTO actor ( + handle, + public_rsa, + public_ed, + private_rsa, + private_ed +) VALUES ( + 'app', + $1, + $2, + $3, + $4 +); diff --git a/src/database/init/05-create-follow.sql b/src/database/init/05-create-follow.sql new file mode 100644 index 0000000..b465823 --- /dev/null +++ b/src/database/init/05-create-follow.sql @@ -0,0 +1,9 @@ +CREATE TABLE follow ( + follower VARCHAR NOT NULL, + followed VARCHAR REFERENCES actor (handle) NOT NULL, + + PRIMARY KEY ( + follower, + followed + ) +); diff --git a/src/database/query/delete-follow.sql b/src/database/query/delete-follow.sql new file mode 100644 index 0000000..f0f7e9d --- /dev/null +++ b/src/database/query/delete-follow.sql @@ -0,0 +1,6 @@ +DELETE FROM + follow +WHERE + follow.follower = $1 + AND + follow.followed = $2; diff --git a/src/database/query/get-actor_app.sql b/src/database/query/get-actor_app.sql new file mode 100644 index 0000000..21ea7dd --- /dev/null +++ b/src/database/query/get-actor_app.sql @@ -0,0 +1,10 @@ +SELECT + actor.handle, + actor.public_rsa, + actor.public_ed, + actor.private_rsa, + actor.private_ed +FROM + actor +WHERE + actor.handle = 'app'; diff --git a/src/database/query/get-actor_guild.sql b/src/database/query/get-actor_guild.sql new file mode 100644 index 0000000..af37678 --- /dev/null +++ b/src/database/query/get-actor_guild.sql @@ -0,0 +1,10 @@ +SELECT + actor.handle, + actor.public_rsa, + actor.public_ed, + actor.private_rsa, + actor.private_ed +FROM + actor +WHERE + actor.handle = 'g' || $1; diff --git a/src/database/query/get-actor_player.sql b/src/database/query/get-actor_player.sql new file mode 100644 index 0000000..43b845b --- /dev/null +++ b/src/database/query/get-actor_player.sql @@ -0,0 +1,10 @@ +SELECT + actor.handle, + actor.public_rsa, + actor.public_ed, + actor.private_rsa, + actor.private_ed +FROM + actor +WHERE + actor.handle = 'u' || $1; diff --git a/src/database/query/put-follow.sql b/src/database/query/put-follow.sql new file mode 100644 index 0000000..c3f810a --- /dev/null +++ b/src/database/query/put-follow.sql @@ -0,0 +1,7 @@ +INSERT INTO follow ( + follower, + followed +) VALUES ( + $1, + $2 +) ON CONFLICT DO NOTHING; diff --git a/src/deno/logging.ts b/src/deno/logging.ts new file mode 100644 index 0000000..d99cad1 --- /dev/null +++ b/src/deno/logging.ts @@ -0,0 +1,18 @@ +import { configure, getConsoleSink, getLogger } from "@logtape/logtape" + + +const l = getLogger(["dotino-veloce", "deno", "logging"]) + + +export async function initLogging() { + await configure({ + sinks: { console: getConsoleSink() }, + filters: {}, + loggers: [ + { category: ["logtape", "meta"], sinks: ["console"], level: "warning" }, + { category: ["fedify"], sinks: ["console"], level: "debug" }, + { category: ["dotino-veloce"], sinks: ["console"], level: "debug" }, + ], + }) + l.info`Logging initialized successfully!` +} diff --git a/src/deno/router.ts b/src/deno/router.ts new file mode 100644 index 0000000..f2b5e21 --- /dev/null +++ b/src/deno/router.ts @@ -0,0 +1,45 @@ +import { getLogger } from "@logtape/logtape" +import { handleHostMeta } from "../dv/hostMeta.ts" +import { DotinoVeloce } from "../dv/index.ts" +import { handleFavicon } from "../dv/favicon.ts" + + +const l = getLogger(["dotino-veloce", "deno", "router"]) + +export function createRouter(ap: DotinoVeloce) { + return async function router(request: Request, _info: Deno.ServeHandlerInfo): Promise { + l.debug`Routing request: ${request}` + + l.debug`Determining request's User-Agent...` + const agent = request.headers.get("User-Agent") + + l.debug`Determining request's URL...` + const url = new URL(request.url) + + l.debug`Determining request's pathname...` + const pathname = url.pathname + + l.info`User-Agent: ${agent} | URL: ${url} | Pathname: ${pathname}` + + if (url.pathname === "/favicon.ico") { + l.debug`Delegating handling to favicon handler...` + return await handleFavicon() + } + if (url.pathname === "/.well-known/host-meta") + { + l.debug`Delegating handling to host-meta generator...` + return await handleHostMeta(url.origin) + } + else + { + l.debug`Delegating request to federation handlers...` + return await ap.federation.fetch( + request, + { + contextData: undefined, + }, + ) + } + } +} + diff --git a/src/deno/server.ts b/src/deno/server.ts new file mode 100644 index 0000000..d0aaae7 --- /dev/null +++ b/src/deno/server.ts @@ -0,0 +1,31 @@ +import { getLogger } from "https://jsr.io/@logtape/logtape/0.6.3/logtape/logger.ts" + + +const l = getLogger(["dotino-veloce", "deno", "server"]) + + +export function doServe(router: Deno.ServeHandler) { + l.info`Starting server...` + Deno.serve( + { + port: 8080, + + onListen(localAddr) { + l.info`Listening on: ${localAddr}` + }, + + // deno-lint-ignore require-await + async onError(error) { + l.error`Error caught at the serve boundary: ${error}` + return new Response( + "An internal server error has occurred.", + { + status: 500, + } + ) + } + }, + + router, + ) +} \ No newline at end of file diff --git a/src/dv/actor.ts b/src/dv/actor.ts new file mode 100644 index 0000000..0944175 --- /dev/null +++ b/src/dv/actor.ts @@ -0,0 +1,196 @@ +import { Actor, Application, Context, Endpoints, Image, Organization, Person, PropertyValue } from "@fedify/fedify" +import { ContextData } from "./index.ts" +import { getLogger } from "@logtape/logtape" +import { escapeHtml } from "@@x/escape" +import { DvComponent } from "./component.ts" + + +const l = getLogger(["dotino-veloce", "dv", "actor", "common"]) +const lApp = getLogger(["dotino-veloce", "dv", "actor", "app"]) +const lPlayer = getLogger(["dotino-veloce", "dv", "actor", "player"]) +const lGuild = getLogger(["dotino-veloce", "dv", "actor", "guild"]) + + +export class ActorComponent extends DvComponent { + static appMatcher = /app/ + static playerMatcher = /u([0-9]+)/ + static guildMatcher = /g([0-9]+)/ + + async getActor(ctx: Context, handle: string): Promise { + l.info`Getting actor: ${handle}` + + let actor = null + + actor ??= await this.getAppActor(ctx, handle) + actor ??= await this.getPlayerActor(ctx, handle) + actor ??= await this.getGuildActor(ctx, handle) + + l.debug`Obtained actor: ${handle}` + + return actor + } + + async getCommonActorProperties(ctx: Context, handle: string): Promise> { + l.info`Generating common actor properties for ${handle}...` + const properties = { + preferredUsername: handle, + id: ctx.getActorUri(handle), + + // This is required for compatibility with a whole lot of servers + publicKeys: (await ctx.getActorKeyPairs(handle)).map(keyPair => keyPair.cryptographicKey), + assertionMethods: (await ctx.getActorKeyPairs(handle)).map(keyPair => keyPair.multikey), + + // Akkoma requires inboxes to be setup to display profiles + // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/web/activity_pub/object_validators/user_validator.ex#L72 + inbox: ctx.getInboxUri(handle), + endpoints: new Endpoints({ + sharedInbox: ctx.getInboxUri(), + }), + } + l.debug`Generated common actor properties for ${handle}: ${properties}` + return properties + } + + async getAppActor(ctx: Context, handle: string): Promise { + lApp.debug`Checking if handle ${handle} matches regex for the app account...` + const match = handle.match(ActorComponent.appMatcher) + if(!match) { + lApp.debug`No match for app account regex, returning null.` + return null + } + lApp.info`Attempting to generate ActivityPub actor for app...` + + lApp.debug`Creating ActivityPub actor for app...` + const actor = new Application({ + ...(await this.getCommonActorProperties(ctx, handle)), + + name: "[TEST] Dotino Veloce", + summary: "App account of a Dotino Veloce instance.", + attachments: [ + new PropertyValue({ + name: "Source code", + value: `https://forge.steffo.eu/steffo/dotino-veloce`, + }), + ] + }) + + lApp.debug`Generated ActivityPub actor for service: ${actor}` + return actor + } + + async getPlayerActor(ctx: Context, handle: string): Promise { + lPlayer.debug`Checking if handle ${handle} matches regex for player accounts...` + const match = handle.match(ActorComponent.playerMatcher) + if(!match) { + lPlayer.debug`No match for player account regex, returning null.` + return null + } + lApp.info`Attempting to generate ActivityPub actor for player...` + + lPlayer.debug`Getting handle ${handle}'s SteamID...` + const steamId = Number.parseInt(match[1] as any) + lPlayer.debug`Handle ${handle}'s SteamID seems to be: ${steamId}` + + lPlayer.debug`Making sure the SteamID parsing didn't explode...` + if(!Number.isFinite(steamId)) { + lPlayer.error`SteamID parsing for ${handle} exploded with ${steamId}, returning null.` + return null + } + + lPlayer.debug`Getting player data for ${steamId}...` + const player = await this.dv.stratz.doQueryPlayer(steamId) + if(player === null) { + lPlayer.debug`No Steam account was found with ID: ${steamId}` + return null + } + + lPlayer.debug`Creating ActivityPub actor for player ${steamId}...` + const actor = new Person({ + ...(await this.getCommonActorProperties(ctx, handle)), + + name: `[TEST] ${escapeHtml(player.name)}`, + icon: new Image({ + url: new URL(player.avatar), + mediaType: "image/jpeg" + }), + attachments: [ + new PropertyValue({ + name: "Steam", + value: `https://steamcommunity.com/profiles/[U:1:${player.id}]`, + }), + new PropertyValue({ + name: "STRATZ", + value: `https://stratz.com/players/${player.id}`, + }), + new PropertyValue({ + name: "OpenDota", + value: `https://www.opendota.com/players/${player.id}`, + }), + new PropertyValue({ + name: "DOTABUFF", + value: `https://www.dotabuff.com/players/${player.id}`, + }), + ], + }) + lPlayer.debug`Generated ActivityPub actor for player ${steamId}: ${actor}` + + return actor + } + + async getGuildActor(ctx: Context, handle: string): Promise { + lGuild.debug`Checking if handle ${handle} matches regex for guild accounts...` + const match = handle.match(ActorComponent.guildMatcher) + if(!match) { + lGuild.debug`No match for guild account regex, returning null.` + return null + } + lApp.info`Attempting to generate ActivityPub actor for guild...` + + lGuild.debug`Getting handle ${handle}'s Guild ID...` + const guildId = Number.parseInt(match[1] as any) + lGuild.debug`Handle ${handle}'s Guild ID seems to be: ${guildId}` + + lGuild.debug`Making sure the Guild ID parsing didn't explode...` + if(Number.isFinite(guildId)) { + lGuild.error`Guild ID parsing for ${handle} exploded with ${guildId}, returning null.` + return null + } + + lPlayer.debug`Getting guild data for ${guildId}...` + const guild = await this.dv.stratz.doQueryGuild(guildId) + if(guild === null) { + lGuild.warn`No guild was found with ID: ${guildId}` + return null + } + + lGuild.debug`Creating ActivityPub actor for guild ${guildId}...` + const actor = new Organization({ + ...(await this.getCommonActorProperties(ctx, handle)), + + name: `[TEST] ${escapeHtml(guild.name)}`, + summary: escapeHtml(guild.description), + icon: new Image({ + url: new URL(`https://steamusercontent-a.akamaihd.net/ugc/${guild.logo}`), + mediaType: "image/jpeg" + }), + published: Temporal.Instant.fromEpochMilliseconds(guild.createdDateTime * 1000), + attachments: [ + new PropertyValue({ + name: "Tag", + value: `[${escapeHtml(guild.tag)}]`, + }), + new PropertyValue({ + name: "Message of the day", + value: escapeHtml(guild.motd), + }), + new PropertyValue({ + name: "STRATZ", + value: `https://stratz.com/guilds/${guild.id}`, + }), + ] + }) + lGuild.debug`Generated ActivityPub actor for guild ${guildId}: ${actor}` + + return actor + } +} \ No newline at end of file diff --git a/src/dv/component.ts b/src/dv/component.ts new file mode 100644 index 0000000..2b7476a --- /dev/null +++ b/src/dv/component.ts @@ -0,0 +1,15 @@ +import { getLogger } from "@logtape/logtape" +import { DotinoVeloce } from "./index.ts" + + +const l = getLogger(["dotino-veloce", "dv", "component"]) + + +export class DvComponent { + dv: DotinoVeloce + + constructor(dv: DotinoVeloce) { + l.debug`Instantiating DvComponent...` + this.dv = dv + } +} diff --git a/src/dv/favicon.ts b/src/dv/favicon.ts new file mode 100644 index 0000000..dc8eace --- /dev/null +++ b/src/dv/favicon.ts @@ -0,0 +1,18 @@ +import { getLogger } from "@logtape/logtape" + + +const l = getLogger(["dotino-veloce", "dv", "favicon"]) + + +export async function handleFavicon(): Promise { + l.info`Returning favicon...` + + return new Response( + await Deno.readFile(".media/icon-128x128_round.png"), + { + headers: { + "Content-Type": "image/png", + }, + }, + ) +} diff --git a/src/dv/follow.ts b/src/dv/follow.ts new file mode 100644 index 0000000..380b63a --- /dev/null +++ b/src/dv/follow.ts @@ -0,0 +1,114 @@ +import { getLogger } from "@logtape/logtape" +import { ContextData } from "./index.ts" +import { Accept, Context, Follow, Reject } from "@fedify/fedify" +import { DvComponent } from "./component.ts" + + +const lPlus = getLogger(["dotino-veloce", "dv", "follow", "plus"]) +const lMinus = getLogger(["dotino-veloce", "dv", "follow", "minus"]) + + +export class FollowComponent extends DvComponent { + async handlePlus(ctx: Context, follow: Follow) { + lPlus.debug`Received Follow activity: ${follow}` + + if(!follow.id) { + lPlus.warn`Missing follow ID, skipping.` + return + } + if(!follow.actorId) { + lPlus.warn`Missing actor ID, skipping.` + return + } + if(!follow.objectId) { + lPlus.warn`Missing object ID, skipping.` + return + } + + lPlus.debug`Attempting to determine who sent the follow request...` + const actor = await follow.getActor(ctx) + + if(!actor) { + lPlus.warn`Failed to determine who sent the follow request, skipping.` + return + } + + lPlus.debug`Attempting to determine who is being followed...` + const object = ctx.parseUri(follow.objectId) + + if(!object) { + lPlus.warn`Failed to determine who is being followed, skipping.` + return + } + if(object.type !== "actor") { + lPlus.warn`Attempting to follow something that is not actor, skipping.` + return + } + + lPlus.info`Confirming follow request from ${follow.actorId} to ${follow.objectId}...` + + lPlus.debug`Storing follow information in the database...` + this.dv.db.putFollow(follow.actorId.toString(), follow.objectId.toString()) + + lPlus.debug`Sending follow Accept activity...` + await ctx.sendActivity(object, "followers", new Accept({ + object: follow, + actor: follow.objectId, + to: follow.actorId, + })) + + lPlus.debug`Successfully accepted follow request!` + } + + async handleMinus(ctx: Context, follow: Follow) { + lMinus.debug`Received Undo Follow activity: ${follow}` + + if(!follow.id) { + lMinus.warn`Missing follow ID, skipping.` + return + } + if(!follow.actorId) { + lMinus.warn`Missing actor ID, skipping.` + return + } + if(!follow.objectId) { + lMinus.warn`Missing object ID, skipping.` + return + } + + lMinus.debug`Attempting to determine who sent the follow request...` + const actor = await follow.getActor(ctx) + + if(!actor) { + lMinus.warn`Failed to determine who sent the follow request, skipping.` + return + } + + lMinus.debug`Attempting to determine who is being followed...` + const object = ctx.parseUri(follow.objectId) + + if(!object) { + lMinus.warn`Failed to determine who is being followed, skipping.` + return + } + if(object.type !== "actor") { + lMinus.warn`Attempting to follow something that is not actor, skipping.` + return + } + + lMinus.info`Undoing follow request from ${follow.actorId} to ${follow.objectId}...` + + lMinus.debug`Deleting follow information from the database...` + this.dv.db.deleteFollow(follow.actorId.toString(), follow.objectId.toString()) + + // Do I really need to do this? + lMinus.debug`Sending follow Reject activity...` + await ctx.sendActivity(object, "followers", new Reject({ + object: follow, + actor: follow.objectId, + to: follow.actorId, + })) + + lMinus.debug`Successfully undone follow request!` + } +} diff --git a/src/dv/hostMeta.ts b/src/dv/hostMeta.ts new file mode 100644 index 0000000..a706be4 --- /dev/null +++ b/src/dv/hostMeta.ts @@ -0,0 +1,21 @@ +import { getLogger } from "@logtape/logtape" + + +const l = getLogger(["dotino-veloce", "dv", "hostMeta"]) + + +// deno-lint-ignore require-await +export async function handleHostMeta(origin: string): Promise { + l.info`Creating host-meta definition for: ${origin}` + + // Akkoma expects host-meta to be correctly setup + // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/web/web_finger.ex#L177 + return new Response( + ``, + { + headers: { + "Content-Type": "application/xml", + }, + }, + ) +} diff --git a/src/dv/index.ts b/src/dv/index.ts new file mode 100644 index 0000000..5cd82b8 --- /dev/null +++ b/src/dv/index.ts @@ -0,0 +1,62 @@ +import { createFederation, Federation, Follow, Undo } from "@fedify/fedify" +import { getLogger } from "https://jsr.io/@logtape/logtape/0.6.3/logtape/logger.ts" +import { StratzAPI } from "../stratz/api.ts" +import { Database } from "../database/index.ts" +import { NodeInfoComponent } from "./nodeinfo.ts" +import { ActorComponent } from "./actor.ts" +import { KeyComponent } from "./key.ts" +import { FollowComponent } from "./follow.ts" +import { UndoComponent } from "./undo.ts" + + +const l = getLogger(["dotino-veloce", "dv", "index"]) + + +export type ContextData = undefined + + +export class DotinoVeloce { + db: Database + stratz: StratzAPI + federation: Federation + + cNodeInfo: NodeInfoComponent + cActor: ActorComponent + cKey: KeyComponent + cFollow: FollowComponent + cUndo: UndoComponent + + constructor(db: Database, stratz: StratzAPI) { + this.db = db + this.stratz = stratz + + this.cNodeInfo = new NodeInfoComponent(this) + this.cActor = new ActorComponent(this) + this.cKey = new KeyComponent(this) + this.cFollow = new FollowComponent(this) + this.cUndo = new UndoComponent(this) + + this.federation = createFederation({ + kv: db.useAsKvStore(), + queue: db.useAsMessageQueue(), + }) + + this.federation + .setActorDispatcher("/users/{identifier}", this.cActor.getActor.bind(this.cActor)) + .setKeyPairsDispatcher(this.cKey.getActorKeys.bind(this.cKey)) + .mapHandle((_, h) => h) + + this.federation + .setInboxListeners("/inbox/{identifier}", "/inbox") + // Akkoma with Authorized Fetch requires this to be set + // https://p.junimo.party/#/junimo.party/s/AnFW6s3OURPMY04LKq?view=full + .setSharedKeyDispatcher(this.cKey.getFetchKey.bind(this.cKey)) + .on(Follow, this.cFollow.handlePlus.bind(this.cFollow)) + .on(Undo, this.cUndo.handle.bind(this.cUndo)) + + this.federation + .setNodeInfoDispatcher("/nodeinfo/2.1", this.cNodeInfo.handle.bind(this.cNodeInfo)) + } + + // REMINDER: A pattern matcher on received activities could be useful, actually. +} \ No newline at end of file diff --git a/src/dv/key.ts b/src/dv/key.ts new file mode 100644 index 0000000..ccdbeec --- /dev/null +++ b/src/dv/key.ts @@ -0,0 +1,68 @@ +import { Context, importJwk, SenderKeyPair } from "@fedify/fedify" +import { ContextData } from "./index.ts" +import { getLogger } from "@logtape/logtape" +import { DvComponent } from "./component.ts" + + +const lActor = getLogger(["dotino-veloce", "dv", "key", "actor"]) +const lFetch = getLogger(["dotino-veloce", "dv", "key", "fetch"]) + + +export class KeyComponent extends DvComponent { + async getActorKeys(_ctx: Context, handle: string): Promise { + lActor.info`Getting actor keys for: ${handle}` + + lActor.debug`Requesting keys from the database...` + const { + public_rsa: publicRsaJwk, + public_ed: publicEdJwk, + private_rsa: privateRsaJwk, + private_ed: privateEdJwk, + } = await this.dv.db.getActorApp() + + lActor.debug`Importing keys from JWK format...` + const publicRsa = await importJwk(publicRsaJwk, "public") + const publicEd = await importJwk(publicEdJwk, "public") + const privateRsa = await importJwk(privateRsaJwk, "private") + const privateEd = await importJwk(privateEdJwk, "private") + + lActor.debug`Bundling keys...` + const result = [ + { + publicKey: publicRsa, + privateKey: privateRsa, + }, + { + publicKey: publicEd, + privateKey: privateEd, + } + ] + lActor.debug`Bundled keys are: ${result}` + + return result + } + + async getFetchKey(ctx: Context): Promise { + lFetch.info`Getting fetch key...` + + lFetch.debug`Requesting key from the database...` + const { + private_rsa: privateKeyJwk, + } = await this.dv.db.getActorApp() + + lFetch.debug`Importing key from JWK format...` + const privateKey = await importJwk(privateKeyJwk, "private") + + lFetch.debug`Getting app actor URI...` + const keyId = ctx.getActorUri("app") + + lFetch.debug`Appending #main-key to the app actor URI...` + keyId.hash = "main-key" + + lFetch.debug`Bundling key...` + const result = {privateKey, keyId} + lFetch.debug`Bundled key is: ${result}` + + return result + } +} \ No newline at end of file diff --git a/src/dv/nodeinfo.ts b/src/dv/nodeinfo.ts new file mode 100644 index 0000000..c6945aa --- /dev/null +++ b/src/dv/nodeinfo.ts @@ -0,0 +1,35 @@ +import { Context, NodeInfo } from "@fedify/fedify" +import { ContextData } from "./index.ts" +import { getLogger } from "@logtape/logtape" +import { DvComponent } from "./component.ts" + + +const l = getLogger(["dotino-veloce", "dv", "nodeinfo"]) + + +export class NodeInfoComponent extends DvComponent { + handle(_ctx: Context): NodeInfo { + l.debug`Creating nodeinfo...` + return { + software: { + name: "dotino-veloce", + version: { + major: 0, + minor: 1, + patch: 0, + }, + repository: new URL("https://forge.steffo.eu/steffo/dotino-veloce"), + }, + protocols: ["activitypub"], + usage: { + users: { + total: 0, + activeHalfyear: 0, + activeMonth: 0, + }, + localPosts: 0, + localComments: 0, + } + } + } +} diff --git a/src/dv/undo.ts b/src/dv/undo.ts new file mode 100644 index 0000000..3baa390 --- /dev/null +++ b/src/dv/undo.ts @@ -0,0 +1,14 @@ +import { getLogger } from "@logtape/logtape" +import { ContextData } from "./index.ts" +import { Context, Undo } from "@fedify/fedify" +import { DvComponent } from "./component.ts" + + +const l = getLogger(["dotino-veloce", "dv", "undo"]) + + +export class UndoComponent extends DvComponent { + async handle(_ctx: Context, undo: Undo) { + l.error`Not implemented: ${undo}` + } +} diff --git a/src/entry/dbInit.ts b/src/entry/dbInit.ts new file mode 100644 index 0000000..530e47e --- /dev/null +++ b/src/entry/dbInit.ts @@ -0,0 +1,12 @@ +import { Database } from "../database/index.ts" +import { initLogging } from "../deno/logging.ts" + + +async function entryDbInit() { + await initLogging() + const db = Database.fromEnv() + await db.reinitializeDev() +} + + +entryDbInit() diff --git a/src/entry/server.ts b/src/entry/server.ts new file mode 100644 index 0000000..fca8b90 --- /dev/null +++ b/src/entry/server.ts @@ -0,0 +1,21 @@ +import { doServe } from "../deno/server.ts" +import { initLogging } from "../deno/logging.ts" +import { StratzAPI } from "../stratz/api.ts" +import { behindProxy, Fetch } from "@hongminhee/x-forwarded-fetch" +import { createRouter } from "../deno/router.ts" +import { DotinoVeloce } from "../dv/index.ts" +import { Database } from "../database/index.ts" + + +async function entryServer() { + await initLogging() + const db = Database.fromEnv() + const stratz = StratzAPI.fromEnv() + const ap = new DotinoVeloce(db, stratz) + const router = createRouter(ap) + const routerBehindProxy = behindProxy(router as Fetch) + await doServe(routerBehindProxy) +} + + +entryServer() diff --git a/src/federation.ts b/src/federation.ts deleted file mode 100644 index 563dad2..0000000 --- a/src/federation.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { createFederation, Person, Application, Image, PropertyValue, Organization, Group } from "@fedify/fedify" -import { kv } from "./redis.ts" -import { getLogger } from "https://jsr.io/@logtape/logtape/0.6.3/logtape/logger.ts" -import { doQueryGuild, doQueryPlayer } from "./graphql/stratz.ts" -import { escapeHtml } from "@@x/escape" - -const l = getLogger(["dotino-veloce", "federation"]) - -l.debug`Creating federation object...` -export const federation = createFederation({ kv }) - -const userMatcher = /u([0-9]+)/ -const guildMatcher = /g([0-9]+)/ - -l.debug`Creating actor dispatcher...` -// deno-lint-ignore require-await -async function actorDispatcher(ctx: any, handle: string) { - l.debug`Determining id of requested actor: ${handle}` - const id = ctx.getActorUri(handle) - l.debug`Requested actor is: ${id.href}` - - if (handle === "service") { - l.info`Dispatching service account...` - return new Application({ - id, - preferredUsername: id.href, - // Akkoma expects URL to be equal to ID - // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/object/fetcher.ex#L287 - url: id, - // Akkoma requires inboxes to be setup to display profiles - // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/web/activity_pub/object_validators/user_validator.ex#L72 - inbox: ctx.getInboxUri(handle), - - name: "[TEST] Dotino Veloce", - summary: "Service account of a Dotino Veloce instance.", - }) - } - - const steamId = Number.parseInt(handle.match(userMatcher)?.[1] as any) - if(Number.isFinite(steamId)) { - l.info`Dispatching user account with Steam ID: ${steamId}` - - const player = await doQueryPlayer(steamId) - - if(player === null) { - l.warn`No Steam account was found with ID: ${steamId}` - return null - } - - return new Person({ - id, - preferredUsername: id.href, - // Akkoma expects URL to be equal to ID - // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/object/fetcher.ex#L287 - url: id, - // Akkoma requires inboxes to be setup to display profiles - // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/web/activity_pub/object_validators/user_validator.ex#L72 - inbox: ctx.getInboxUri(handle), - - name: `[TEST] ${escapeHtml(player.name)}`, - icon: new Image({ - url: new URL(player.avatar), - mediaType: "image/jpeg" - }), - attachments: [ - new PropertyValue({ - name: "Steam", - value: `https://steamcommunity.com/profiles/[U:1:${player.id}]`, - }), - new PropertyValue({ - name: "STRATZ", - value: `https://stratz.com/players/${player.id}`, - }), - new PropertyValue({ - name: "OpenDota", - value: `https://www.opendota.com/players/${player.id}`, - }), - new PropertyValue({ - name: "DOTABUFF", - value: `https://www.dotabuff.com/players/${player.id}`, - }), - ] - }) - } - - const guildId = Number.parseInt(handle.match(guildMatcher)?.[1] as any) - if(Number.isFinite(guildId)) { - l.info`Dispatching guild with Guild ID: ${guildId}` - - const guild = await doQueryGuild(guildId) - - if(guild === null) { - l.warn`No guild was found with ID: ${guildId}` - return null - } - - return new Group({ - id, - preferredUsername: id.href, - // Akkoma expects URL to be equal to ID - // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/object/fetcher.ex#L287 - url: id, - // Akkoma requires inboxes to be setup to display profiles - // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/web/activity_pub/object_validators/user_validator.ex#L72 - inbox: ctx.getInboxUri(handle), - - name: `[TEST] ${escapeHtml(guild.name)}`, - summary: escapeHtml(guild.description), - icon: new Image({ - url: new URL(`https://steamusercontent-a.akamaihd.net/ugc/${guild.logo}`), - mediaType: "image/jpeg" - }), - published: Temporal.Instant.fromEpochMilliseconds(guild.createdDateTime * 1000), - attachments: [ - new PropertyValue({ - name: "Tag", - value: `[${escapeHtml(guild.tag)}]`, - }), - new PropertyValue({ - name: "Message of the day", - value: escapeHtml(guild.motd), - }), - new PropertyValue({ - name: "STRATZ", - value: `https://stratz.com/guilds/${guild.id}`, - }), - ] - }) - } - - l.warn`No dispatcher was found for handle: ${handle}` - return null - -} - -l.debug`Connecting actor dispatcher to federation object...` -federation.setActorDispatcher("/users/{identifier}", actorDispatcher) - -l.debug`Initializing inbox listener...` -federation.setInboxListeners("/inbox/{identifier}") // I don't really care about the shared inbox for this project diff --git a/src/graphql/stratz.ts b/src/graphql/stratz.ts deleted file mode 100644 index 6bf4e65..0000000 --- a/src/graphql/stratz.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { getLogger } from "@logtape/logtape" - -const l = getLogger(["dotino-veloce", "graphql"]) - -l.debug`Getting Stratz API URL from environment variable DOTINO_STRATZ_URL...` -const urlString = Deno.env.get("DOTINO_STRATZ_URL") -if(!urlString) { - l.error`DOTINO_STRATZ_URL is unset.` - throw new Error("DOTINO_STRATZ_URL is unset.") -} - -l.debug`Attempting to parse Stratz API URL...` -const url = new URL(urlString) - -l.debug`Getting Stratz API key from environment variable DOTINO_STRATZ_KEY...` -const key = Deno.env.get("DOTINO_STRATZ_KEY") -if(!key) { - l.error`DOTINO_STRATZ_KEY is unset.` - throw new Error("DOTINO_STRATZ_KEY is unset.") -} - -// Little trick to get VSCode to highlight queries! -const graphql = String.raw - -export async function doQuery(query: string, variables: object) { - const request = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Accept": "application/json", - "Authorization": `Bearer ${key}`, - "User-Agent": "STRATZ_API", - }, - body: JSON.stringify({query, variables}) - }) - const data = await request.json() - - if(data["error"]) { - throw new Error("GraphQL query failed.", data["error"]) - } - - return data["data"] -} - -export async function doQueryPlayer(steamId: number): Promise<{ - communityVisibleState: number, - isAnonymous: boolean, - id: number, - name: string, - avatar: string, -} | null> { - l.info`Querying player ${steamId} on the Stratz API...` - const response = await doQuery( - graphql` - query ($steamId: Long!) { - player (steamAccountId: $steamId) { - steamAccount { - communityVisibleState - isAnonymous - id - name - avatar - } - } - } - `, - {steamId} - ) - return response?.player?.steamAccount ?? null -} - -export async function doQueryGuild(guildId: number): Promise<{ - id: number, - motd: string, - name: string, - tag: string, - logo: string, - description: string, - createdDateTime: number, -} | null> { - l.info`Querying guild ${guildId} on the Stratz API...` - const response = await doQuery( - graphql` - query ($guildId: Int!) { - guild(id: $guildId) { - id, - motd, - name, - tag, - logo, - description, - createdDateTime, - } - } - `, - {guildId} - ) - return response?.guild ?? null -} diff --git a/src/handler.ts b/src/handler.ts deleted file mode 100644 index da7fb02..0000000 --- a/src/handler.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { getLogger } from "@logtape/logtape" -import { behindProxy, Fetch } from "@hongminhee/x-forwarded-fetch" -import { federation } from "./federation.ts" - -const l = getLogger(["dotino-veloce", "handler"]) - -l.debug`Creating Deno handler...` -function handler(request: Request, _info: Deno.ServeHandlerInfo) { - l.debug`Received a request, processing...` - - const agent = request.headers.get("User-Agent") - const requestUrl = new URL(request.url) - l.debug`Received request from ${agent} to ${requestUrl.href}` - - // Akkoma expects host-meta to be correctly setup - // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/web/web_finger.ex#L177 - l.debug`Routing request to: ${requestUrl.pathname}` - if (requestUrl.pathname === "/.well-known/host-meta") { - l.debug`Intercepting request to inject host-meta for ${requestUrl.origin}` - return new Response( - ``, - { - headers: { - "Content-Type": "application/xml", - }, - }, - ) - } - - l.debug`Delegating request to Federation...` - return federation.fetch( - request, - { - contextData: undefined, - }, - ) -} - -l.debug`Creating proxyied Deno handler...` -export const proxyHandler: Deno.ServeHandler = behindProxy(handler as Fetch) // Should be good. diff --git a/src/main.ts b/src/main.ts deleted file mode 100644 index 82c2f3a..0000000 --- a/src/main.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { configure, getConsoleSink, getLogger } from "@logtape/logtape" -import { proxyHandler } from "./handler.ts" - -await configure({ - sinks: { console: getConsoleSink() }, - filters: {}, - loggers: [ - { category: ["logtape", "meta"], sinks: ["console"], level: "warning" }, - { category: ["fedify"], sinks: ["console"], level: "info" }, - { category: ["dotino-veloce"], sinks: ["console"], level: "debug" }, - ], -}) - -const l = getLogger(["dotino-veloce", "main"]) - -l.info`Starting server...` -Deno.serve( - { - port: 8080, - onListen: (_localAddr) => { - }, - }, - proxyHandler, -) diff --git a/src/redis.ts b/src/redis.ts deleted file mode 100644 index deae4f9..0000000 --- a/src/redis.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { RedisKvStore } from "@fedify/redis" -import { getLogger } from "@logtape/logtape" -import { Redis } from "@@npm/ioredis" - -const l = getLogger(["dotino-veloce", "redis"]) - -l.debug`Creating redis object...` -export const redis = new Redis({}) - -l.debug`Creating federation object...` -export const kv = new RedisKvStore(redis, {}) diff --git a/src/stratz/api.ts b/src/stratz/api.ts new file mode 100644 index 0000000..fe35a60 --- /dev/null +++ b/src/stratz/api.ts @@ -0,0 +1,143 @@ +import { getLogger } from "@logtape/logtape" + + +const l = getLogger(["dotino-veloce", "stratz", "api"]) +const graphql = String.raw + + +export type QueryPlayer = { + communityVisibleState: number, + isAnonymous: boolean, + id: number, + name: string, + avatar: string, +} + +export type QueryGuild = { + id: number, + motd: string, + name: string, + tag: string, + logo: string, + description: string, + createdDateTime: number, +} + +export class StratzAPI { + url: URL + key: string + + constructor(url: string, key: string) { + l.info`Creating new Stratz API client sending requests at: ${url}` + + l.debug`Using Stratz API key: ${key}` + this.key = key + + l.debug`Attempting to parse Stratz API URL...` + this.url = new URL(url) + l.debug`Stratz API URL is: ${this.url}` + } + + static fromEnv(): StratzAPI { + l.debug`Getting Stratz API URL from environment variable DOTINO_STRATZ_URL...` + const url = Deno.env.get("DOTINO_STRATZ_URL") + if(!url) { + l.error`DOTINO_STRATZ_URL is unset.` + throw new Error("DOTINO_STRATZ_URL is unset.") + } + + l.debug`Getting Stratz API key from environment variable DOTINO_STRATZ_KEY...` + const key = Deno.env.get("DOTINO_STRATZ_KEY") + if(!key) { + l.error`DOTINO_STRATZ_KEY is unset.` + throw new Error("DOTINO_STRATZ_KEY is unset.") + } + + return new StratzAPI(url, key) + } + + async doQuery(query: string, variables: object): Promise { + l.debug`Sending to Stratz API at ${this.url} query ${query} with variables ${variables}` + const response = await fetch(this.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": `Bearer ${this.key}`, + "User-Agent": "STRATZ_API", // Stratz requires this specific User-Agent in API calls + }, + body: JSON.stringify({query, variables}) + }) + l.debug`Received Stratz API response: ${response}` + + l.debug`Parsing Stratz API response as JSON...` + const parsed = await response.json() + l.debug`Parsed Stratz API response as: ${parsed}` + + const error = parsed["error"] + if(error) { + l.error`GraphQL query failed: ${error}` + throw new Error("GraphQL query failed.", error) + } + + l.debug`Attempting to get data from the parsed API response...` + const data = parsed["data"] + l.debug`Got data from the parsed API response: ${data}` + + return data + } + + async doQueryPlayer(steamId: number): Promise { + const query = graphql` + query ($steamId: Long!) { + player (steamAccountId: $steamId) { + steamAccount { + communityVisibleState + isAnonymous + id + name + avatar + } + } + } + ` + const variables = {steamId} + + l.info`Querying player ${steamId} on the Stratz API...` + const response = await this.doQuery(query, variables) + l.debug`Player query successful, received: ${response}` + + l.debug`Attempting to retrieve player's Steam account from the Stratz API response...` + const steamAccount = response?.player?.steamAccount ?? null + l.debug`Player's Steam account is: ${steamAccount}` + + return steamAccount + } + + async doQueryGuild(guildId: number): Promise { + const query = graphql` + query ($guildId: Int!) { + guild(id: $guildId) { + id, + motd, + name, + tag, + logo, + description, + createdDateTime, + } + } + ` + const variables = {guildId} + + l.info`Querying guild ${guildId} on the Stratz API...` + const response = await this.doQuery(query, variables) + l.debug`Player query successful, received: ${response}` + + l.debug`Attempting to retrieve guild data from the Stratz API response...` + const guildData = response?.guild ?? null + l.debug`Guild data is: ${guildData}` + + return guildData + } +} diff --git a/tests/graphql_test.ts b/tests/graphql_stratz_test.ts similarity index 93% rename from tests/graphql_test.ts rename to tests/graphql_stratz_test.ts index 23ec1cf..252c82f 100644 --- a/tests/graphql_test.ts +++ b/tests/graphql_stratz_test.ts @@ -1,5 +1,5 @@ import { configure, getConsoleSink } from "@logtape/logtape" -import { doQueryPlayer } from "../src/graphql/stratz.ts" +import { doQueryPlayer } from "../src/stratz/api.ts" import { assert, assertEquals} from "@std/assert"