diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql index ab91ccf751e71d5c94e753da09e49315eb5851be..2d49d28b67547d8c9f5c15736fc777d1164fe8f8 100644 --- a/hasura/metadata/actions.graphql +++ b/hasura/metadata/actions.graphql @@ -1,3 +1,13 @@ +type Mutation { + addTransaction( + id: String! + address: String! + hash: String! + signature: String! + comment: String! + ): AddTransactionResponse +} + type Mutation { deleteProfile( address: String! @@ -54,3 +64,8 @@ type MigrateProfileResponse { message: String! } +type AddTransactionResponse { + success: Boolean! + message: String! +} + diff --git a/hasura/metadata/actions.yaml b/hasura/metadata/actions.yaml index a067b7d3e240ef2f65cc1a687ef391b9fcd05640..b03cfce6a65fb6beb9c2f1615410f6efe8b35c51 100644 --- a/hasura/metadata/actions.yaml +++ b/hasura/metadata/actions.yaml @@ -1,4 +1,11 @@ actions: + - name: addTransaction + definition: + kind: synchronous + handler: http://host.docker.internal:3000/add-transaction + permissions: + - role: public + comment: addTransaction - name: deleteProfile definition: kind: synchronous @@ -30,4 +37,5 @@ custom_types: - name: UpdateProfileResponse - name: DeleteProfileResponse - name: MigrateProfileResponse + - name: AddTransactionResponse scalars: [] diff --git a/hasura/metadata/databases/default/tables/public_transactions.yaml b/hasura/metadata/databases/default/tables/public_transactions.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e8bbb909d53726dc2b0841687c5b79089bc3e446 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_transactions.yaml @@ -0,0 +1,3 @@ +table: + name: transactions + schema: public diff --git a/hasura/metadata/databases/default/tables/tables.yaml b/hasura/metadata/databases/default/tables/tables.yaml index 6dc41508ffaf12842ee7663763870f4b4529fc01..722f2fa78476e35a7eb5b79f6798d3abd92a725b 100644 --- a/hasura/metadata/databases/default/tables/tables.yaml +++ b/hasura/metadata/databases/default/tables/tables.yaml @@ -1 +1,2 @@ - "!include public_profiles.yaml" +- "!include public_transactions.yaml" diff --git a/hasura/migrations/default/1705109757208_add-transactions/up.sql b/hasura/migrations/default/1705109757208_add-transactions/up.sql new file mode 100644 index 0000000000000000000000000000000000000000..a72e2ae984343f43b77881e8a9930018f7ee0889 --- /dev/null +++ b/hasura/migrations/default/1705109757208_add-transactions/up.sql @@ -0,0 +1,29 @@ +SET check_function_bodies = false; +CREATE TABLE public.profiles ( + address text NOT NULL, + avatar bytea, + description text, + geoloc point, + title text, + city text, + socials jsonb, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); +CREATE FUNCTION public.bytea_to_base64(data_row public.profiles) RETURNS text + LANGUAGE plpgsql STABLE + AS $$ +BEGIN + RETURN ENCODE(data_row.avatar, 'base64'); +END; +$$; +CREATE TABLE public.transactions ( + id text NOT NULL, + comment text NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); +COMMENT ON TABLE public.transactions IS 'Store transactions comments'; +ALTER TABLE ONLY public.profiles + ADD CONSTRAINT profiles_pkey PRIMARY KEY (address); +ALTER TABLE ONLY public.transactions + ADD CONSTRAINT transactions_pkey PRIMARY KEY (id); diff --git a/index.ts b/index.ts index 09f50263c11888116d10028771f258dff86e57a7..37865a6cf7532aab91082ebd9787ae5cbe65de6f 100644 --- a/index.ts +++ b/index.ts @@ -15,6 +15,7 @@ import { } from "./lib/utils.ts"; import ApiDuniter from "./lib/duniter_connect.ts"; import { DuniterService } from "./lib/duniter_service.ts"; +import { addTransaction } from "./lib/add_transaction.ts"; // Determine the environment const isProduction = Deno.env.get("PRODUCTION") === "true"; @@ -84,6 +85,10 @@ router.post( "/migrate-profile-data", async (ctx: Context) => await migrateProfile(ctx, client), ); +router.post( + "/add-transaction", + async (ctx: Context) => await addTransaction(ctx, client), +); app.use(router.routes()); app.use(router.allowedMethods()); diff --git a/lib/add_transaction.ts b/lib/add_transaction.ts new file mode 100644 index 0000000000000000000000000000000000000000..bfc3dfface847ce9e70dbf5f91f460e256efe66a --- /dev/null +++ b/lib/add_transaction.ts @@ -0,0 +1,89 @@ +import { Context } from "https://deno.land/x/oak@v12.6.1/context.ts"; +import { Client } from "https://deno.land/x/postgres@v0.17.0/client.ts"; +import { + SignatureResponse, + signatureResponseMessages, + verifySignature, +} from "./signature_verify.ts"; +import { Transaction } from "./types.ts"; +import { checkRecordExist } from "./utils.ts"; + +export async function addTransaction(ctx: Context, client: Client) { + try { + const body = await ctx.request.body().value; + const transaction: Transaction = body.variables || body.input || {}; + + // Validate input + if (!transaction.id || !transaction.comment) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + message: "transaction id and comment are required.", + }; + return; + } + + // Verify signature + const signatureResult = await verifySignature(transaction); + if (signatureResult !== SignatureResponse.valid) { + ctx.response.status = 400; + console.error( + "Invalid signature: " + signatureResponseMessages[signatureResult], + ); + ctx.response.body = { + success: false, + message: "Invalid signature: " + + signatureResponseMessages[signatureResult], + }; + return; + } + + // Verify new profile doesn't exists + if (await checkRecordExist(client, 'transactions', 'id', transaction.id)) { + ctx.response.status = 422; + console.error(`ID ${transaction.id} already exist.`); + ctx.response.body = { + success: false, + message: `ID ${transaction.id} already exist.`, + }; + return; + } + + // Prepare and execute database query for deletion + const addTransactionQuery = ` + INSERT INTO transactions (id, comment) + VALUES ($1, $2); + `; + + + try { + await client.queryObject({ + text: addTransactionQuery, + args: [transaction.id, transaction.comment], + }); + ctx.response.status = 200; + console.log( + `Transaction with ID ${transaction.id} has been insert.`, + ); + ctx.response.body = { + success: true, + message: + `Transaction with ID ${transaction.id} has been insert.`, + }; + } catch (error) { + console.error("Database error in insert transaction:", error); + ctx.response.status = 500; + ctx.response.body = { + success: false, + message: "Internal server error: " + error, + }; + } + } catch (error) { + console.error("Error insert transaction:", error); + ctx.response.status = 500; + ctx.response.body = { + success: false, + message: "Error insert transaction: " + error, + }; + } +} diff --git a/lib/delete_profile.ts b/lib/delete_profile.ts index e91f904ad1d1d3f2baebfebfedd53062fd3807e2..1de78447aaa8622d6b013a6c3d298c4b744859fa 100644 --- a/lib/delete_profile.ts +++ b/lib/delete_profile.ts @@ -2,7 +2,7 @@ import { Context } from "https://deno.land/x/oak@v12.6.1/context.ts"; import { Client } from "https://deno.land/x/postgres@v0.17.0/client.ts"; import { Profile } from "./types.ts"; import { SignatureResponse, verifySignature } from "./signature_verify.ts"; -import { checkProfileExist } from "./utils.ts"; +import { checkRecordExist } from "./utils.ts"; export async function deleteProfile(ctx: Context, client: Client) { try { @@ -22,7 +22,7 @@ export async function deleteProfile(ctx: Context, client: Client) { } // Verify if profile exists - if (!await checkProfileExist(client, profile.address)) { + if (!await checkRecordExist(client, 'profiles', 'address', profile.address)) { ctx.response.status = 404; console.error(`Profile ${profile.address} does not exist.`); ctx.response.body = { diff --git a/lib/duniter_service.ts b/lib/duniter_service.ts index 133cab9a7d34bb42aad49486ec75574883904d6d..fdbf0c28977546f649b7dadd75d5d6634b65ea90 100644 --- a/lib/duniter_service.ts +++ b/lib/duniter_service.ts @@ -3,7 +3,7 @@ import { ApiPromise } from "https://deno.land/x/polkadot@0.2.45/api/mod.ts"; import { type EventRecord } from "https://deno.land/x/polkadot@0.2.45/types/interfaces/system/index.ts"; import { BN } from "https://deno.land/x/polkadot@0.2.45/util/bn/index.ts"; import { Client } from "https://deno.land/x/postgres@v0.17.0/client.ts"; -import { checkProfileExist } from "./utils.ts"; +import { checkRecordExist } from "./utils.ts"; export class DuniterService { private api!: ApiPromise; @@ -98,7 +98,7 @@ export class DuniterService { const killedAddress = event.event.data[0].toString(); // Verify if profile exists - if (!await checkProfileExist(client, killedAddress)) { + if (!await checkRecordExist(client, 'profiles', 'address', killedAddress)) { console.log(`Profile ${killedAddress} does not exist.`); return; } diff --git a/lib/migrate_profile.ts b/lib/migrate_profile.ts index 3db3ec3e3fc83c56b9b30285b00f359d5269e7ac..64a6315874b1a5f1ef063754c0d48e743d18e2d2 100644 --- a/lib/migrate_profile.ts +++ b/lib/migrate_profile.ts @@ -6,7 +6,7 @@ import { signatureResponseMessages, verifySignature, } from "./signature_verify.ts"; -import { checkProfileExist } from "./utils.ts"; +import { checkRecordExist } from "./utils.ts"; export async function migrateProfile(ctx: Context, client: Client) { try { @@ -53,7 +53,7 @@ export async function migrateProfile(ctx: Context, client: Client) { } // Verify old profile exists - if (!await checkProfileExist(client, profile.oldAddress)) { + if (!await checkRecordExist(client, 'profiles', 'address', profile.oldAddress)) { ctx.response.status = 404; console.error(`Profile ${profile.oldAddress} does not exist.`); ctx.response.body = { @@ -64,7 +64,7 @@ export async function migrateProfile(ctx: Context, client: Client) { } // Verify new profile doesn't exists - if (await checkProfileExist(client, profile.address)) { + if (await checkRecordExist(client, 'profiles', 'address', profile.address)) { ctx.response.status = 422; console.error(`Profile ${profile.address} already exist.`); ctx.response.body = { diff --git a/lib/signature_verify.ts b/lib/signature_verify.ts index eb618897dd14dbdea7146ba39b0cf011fb3073c2..f511b137fe713f7d00d8bb938d36d0cf18aad9aa 100644 --- a/lib/signature_verify.ts +++ b/lib/signature_verify.ts @@ -2,7 +2,7 @@ import { base64Decode, signatureVerify, } from "https://deno.land/x/polkadot@0.2.45/util-crypto/mod.ts"; -import { Profile } from "./types.ts"; +import { Profile, Transaction } from "./types.ts"; export enum SignatureResponse { valid, @@ -21,7 +21,7 @@ export const signatureResponseMessages: { [key in SignatureResponse]: string } = }; export async function verifySignature( - profile: Profile, + profile: Profile | Transaction, ): Promise<SignatureResponse> { let payload: string; let addressSign: string; @@ -42,6 +42,16 @@ export async function verifySignature( address, }); addressSign = oldAddress!; + // If comment is present, then we are on a add transaction event + } else if ("comment" in profile) { + const { address, id, comment } = profile; + payload = JSON.stringify({ + id, + address, + comment, + }); + addressSign = address!; + // Else we are on an update or delete profilie event } else { const { address, description, avatarBase64, geoloc, title, city, socials } = profile; diff --git a/lib/types.ts b/lib/types.ts index fd6a481fc494fc1ef6a9bd02e8803aaea4dd6380..f6d4b5684acc79ab1633f5a3f81236da4a93648b 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -13,3 +13,11 @@ export type Profile = { socials?: SocialInfo[]; oldAddress?: string; }; + +export type Transaction = { + id: string; + address: string; + comment: string; + hash: string; + signature: string; +} diff --git a/lib/update_profile.ts b/lib/update_profile.ts index 299f8e3b6d363801186e7a0cb423cc54d0ab6133..e7143ecdea09726336991376d6c8181dd13ec2aa 100644 --- a/lib/update_profile.ts +++ b/lib/update_profile.ts @@ -1,7 +1,7 @@ import { Context } from "https://deno.land/x/oak@v12.6.1/context.ts"; import { Client } from "https://deno.land/x/postgres@v0.17.0/client.ts"; import { SignatureResponse, verifySignature } from "./signature_verify.ts"; -import { checkProfileExist, convertBase64ToBytea } from "./utils.ts"; +import { checkRecordExist, convertBase64ToBytea } from "./utils.ts"; import { Profile } from "./types.ts"; import { DuniterService } from "./duniter_service.ts"; @@ -26,7 +26,7 @@ export async function updateProfile(ctx: Context, client: Client) { // Verify wallet exist in blockchain const balanceService = new DuniterService(); if ( - !await checkProfileExist(client, profile.address) && + !await checkRecordExist(client, 'profiles', 'address', profile.address) && await balanceService.getBalance(profile.address) === 0 ) { ctx.response.status = 400; diff --git a/lib/utils.ts b/lib/utils.ts index d1b9c8451bfc232099debdabb90fe82f3cb385c8..dca2e06dc3a1aa8a71158e1e4110e3d02e781c42 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -99,15 +99,18 @@ export async function waitForTableCreation( throw new Error(`Table ${tableName} not found after ${maxAttempts} try.`); } -export async function checkProfileExist( +export async function checkRecordExist( client: Client, - address: string, + tableName: string, + columnName: string, + value: string ): Promise<boolean> { - const query = `SELECT 1 FROM profiles WHERE address = $1;`; + const query = `SELECT 1 FROM ${tableName} WHERE ${columnName} = $1;`; const result = await client.queryObject({ text: query, - args: [address], + args: [value], }); const rowCount = result.rowCount ?? 0; return rowCount > 0; } + diff --git a/scripts/export-migrations.sh b/scripts/export-migrations.sh index 1388a6598088cb9bd85d6d2b8c85bdfa0990bdf5..db40ee0b18e148a74807aa4435776c071dcca994 100755 --- a/scripts/export-migrations.sh +++ b/scripts/export-migrations.sh @@ -3,7 +3,10 @@ export $(cat .env | grep -E 'HASURA_GRAPHQL_ADMIN_SECRET|HASURA_LISTEN_PORT') endpoint="http://localhost:$HASURA_LISTEN_PORT" -hasura migrate create "init" --from-server --endpoint $endpoint --admin-secret $HASURA_GRAPHQL_ADMIN_SECRET --database-name default +name=$1 +[[ ! $name ]] && echo "Please set a name for this migration" && exit 1 + +hasura migrate create $name --from-server --endpoint $endpoint --admin-secret $HASURA_GRAPHQL_ADMIN_SECRET --database-name default # To manually apply saved migrations: # hasura migrate apply --endpoint $endpoint --admin-secret $HASURA_GRAPHQL_ADMIN_SECRET --database-name default \ No newline at end of file