diff --git a/.github/workflows/continuous-delivery.yaml b/.github/workflows/continuous-delivery.yaml index ec8a39f..be018fb 100644 --- a/.github/workflows/continuous-delivery.yaml +++ b/.github/workflows/continuous-delivery.yaml @@ -92,10 +92,11 @@ jobs: supabase functions deploy generate-magic-link-v1 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json supabase functions deploy image-proxy-v1 --no-verify-jwt --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json supabase functions deploy profile-v1 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json + supabase functions deploy profile-v2 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json + supabase functions deploy revenuecat-webhooks-v1 --no-verify-jwt --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json supabase functions deploy stripe-create-billing-portal-link-v1 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json supabase functions deploy stripe-create-checkout-session-v1 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json supabase functions deploy stripe-webhooks-v1 --no-verify-jwt --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json - supabase functions deploy revenuecat-webhooks-v1 --no-verify-jwt --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json - name: Push Database Migration and Deploy Functions if: ${{ github.event_name == 'release' && github.event.action == 'published' }} @@ -113,10 +114,11 @@ jobs: supabase functions deploy generate-magic-link-v1 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json supabase functions deploy image-proxy-v1 --no-verify-jwt --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json supabase functions deploy profile-v1 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json + supabase functions deploy profile-v2 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json + supabase functions deploy revenuecat-webhooks-v1 --no-verify-jwt --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json supabase functions deploy stripe-create-billing-portal-link-v1 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json supabase functions deploy stripe-create-checkout-session-v1 --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json supabase functions deploy stripe-webhooks-v1 --no-verify-jwt --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json - supabase functions deploy revenuecat-webhooks-v1 --no-verify-jwt --project-ref $PROJECT_ID --import-map supabase/functions/import_map.json # The "Web" job builds the Flutter web app and publishes it to Cloudflare Pages. The job only runs when a commit is # pushed to the main branch or a new tag is created. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1d3a789..b60e8ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -326,10 +326,11 @@ supabase functions deploy delete-user-v1 --project-ref --import-map supabase functions deploy generate-magic-link-v1 --project-ref --import-map supabase/functions/import_map.json supabase functions deploy image-proxy-v1 --no-verify-jwt --project-ref --import-map supabase/functions/import_map.json supabase functions deploy profile-v1 --project-ref --import-map supabase/functions/import_map.json +supabase functions deploy profile-v2 --project-ref --import-map supabase/functions/import_map.json +supabase functions deploy revenuecat-webhooks-v1 --no-verify-jwt --project-ref --import-map supabase/functions/import_map.json supabase functions deploy stripe-create-billing-portal-link-v1 --project-ref --import-map supabase/functions/import_map.json supabase functions deploy stripe-create-checkout-session-v1 --project-ref --import-map supabase/functions/import_map.json supabase functions deploy stripe-webhooks-v1 --no-verify-jwt --project-ref --import-map supabase/functions/import_map.json -supabase functions deploy revenuecat-webhooks-v1 --no-verify-jwt --project-ref --import-map supabase/functions/import_map.json ``` Now we have to do some manual steps to finish the setup of our Supabase project: diff --git a/app/lib/repositories/profile_repository.dart b/app/lib/repositories/profile_repository.dart index 039d6d0..43148e6 100644 --- a/app/lib/repositories/profile_repository.dart +++ b/app/lib/repositories/profile_repository.dart @@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:feeddeck/models/profile.dart'; -import 'package:feeddeck/models/source.dart'; import 'package:feeddeck/utils/api_exception.dart'; enum FDProfileStatus { @@ -13,6 +12,9 @@ enum FDProfileStatus { initialized, } +/// The [ProfileRepository] is used to fetch and update a users profile data. +/// The users profile contains all the required information for the users +/// subscription status and the users connected accounts. class ProfileRepository with ChangeNotifier { FDProfileStatus _status = FDProfileStatus.uninitialized; FDProfile? _profile; @@ -23,6 +25,8 @@ class ProfileRepository with ChangeNotifier { _profile?.subscriptionProvider; bool get accountGithub => _profile?.accountGithub ?? false; + /// [init] is used to fetch the users profile from the `profile-v2` edge + /// function. Future init(bool force) async { if (_status == FDProfileStatus.initialized && force == false) { return; @@ -30,7 +34,7 @@ class ProfileRepository with ChangeNotifier { try { final result = await Supabase.instance.client.functions.invoke( - 'profile-v1', + 'profile-v2/getProfile', method: HttpMethod.get, ); @@ -47,21 +51,25 @@ class ProfileRepository with ChangeNotifier { } } + /// [setTier] is used to update the users tier. We do not have to make an API + /// call to update the tier in the database, because this is done via Webhooks + /// by the connected payment provider. + /// + /// This is only required to reflect the update in the Flutter app. void setTier(FDProfileTier tier) { _profile?.tier = tier; notifyListeners(); } - Future addGithubAccount(String token) async { + /// [githubAddAccount] is used to add a GitHub account to the users profile. + /// For this the user must provide an private access token with the required + /// scopes. + Future githubAddAccount(String token) async { final result = await Supabase.instance.client.functions.invoke( - 'profile-v1', + 'profile-v2/githubAddAccount', method: HttpMethod.post, body: { - 'action': 'add-account', - 'sourceType': FDSourceType.github.toShortString(), - 'options': { - 'token': token, - }, + 'token': token, }, ); @@ -73,14 +81,12 @@ class ProfileRepository with ChangeNotifier { notifyListeners(); } - Future deleteGithubAccount() async { + /// [githubDeleteAccount] deletes the users connected GitHub account. For that + /// we delete the GitHub access token from the database. + Future githubDeleteAccount() async { final result = await Supabase.instance.client.functions.invoke( - 'profile-v1', - method: HttpMethod.post, - body: { - 'action': 'delete-account', - 'sourceType': FDSourceType.github.toShortString(), - }, + 'profile-v2/githubDeleteAccount', + method: HttpMethod.delete, ); if (result.status != 200) { diff --git a/app/lib/widgets/settings/accounts/settings_accounts_github.dart b/app/lib/widgets/settings/accounts/settings_accounts_github.dart index c63ebcd..f88445c 100644 --- a/app/lib/widgets/settings/accounts/settings_accounts_github.dart +++ b/app/lib/widgets/settings/accounts/settings_accounts_github.dart @@ -35,7 +35,7 @@ class SettingsAccountsGithub extends StatelessWidget { await Provider.of( context, listen: false, - ).deleteGithubAccount(); + ).githubDeleteAccount(); } catch (_) {} } @@ -166,7 +166,7 @@ class _SettingsAccountsGithubAddState extends State { await Provider.of( context, listen: false, - ).addGithubAccount(_tokenController.text); + ).githubAddAccount(_tokenController.text); setState(() { _isLoading = false; diff --git a/supabase/functions/profile-v1/index.ts b/supabase/functions/profile-v1/index.ts index 0555d6a..8961a2f 100644 --- a/supabase/functions/profile-v1/index.ts +++ b/supabase/functions/profile-v1/index.ts @@ -12,6 +12,9 @@ import { FEEDDECK_SUPABASE_URL, } from "../_shared/utils/constants.ts"; +/** + * DEPRECATED: This function is deprecated and will be removed in the future. Please use the new `profile-v2` function. + */ serve(async (req) => { /** * We need to handle the preflight request for CORS as it is described in the Supabase documentation: diff --git a/supabase/functions/profile-v2/github.ts b/supabase/functions/profile-v2/github.ts new file mode 100644 index 0000000..08de275 --- /dev/null +++ b/supabase/functions/profile-v2/github.ts @@ -0,0 +1,83 @@ +import { SupabaseClient, User } from "@supabase/supabase-js"; + +import { corsHeaders } from "../_shared/utils/cors.ts"; +import { log } from "../_shared/utils/log.ts"; +import { encrypt } from "../_shared/utils/encrypt.ts"; + +/** + * `githubAddAccount` adds a new GitHub account to the users profile. A user must only provide a private access token to + * connect his GitHub account. We encrypt the token before we store it in the database. + */ +export const githubAddAccount = async ( + supabaseClient: SupabaseClient, + user: User, + data: { token?: string } | null, +): Promise => { + if (!data || !data.token) { + return new Response(JSON.stringify({ error: "Bad Request" }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 400, + }); + } + + const { error: updateError } = await supabaseClient.from( + "profiles", + ).update({ + "accountGithub": { token: await encrypt(data.token) }, + }).eq("id", user.id); + if (updateError) { + log("error", "Failed to update user profile", { + "user": user, + "error": updateError, + }); + return new Response( + JSON.stringify({ error: "Failed to update profile" }), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 500, + }, + ); + } + return new Response( + undefined, + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 200, + }, + ); +}; + +/** + * `githubDeleteAccount` deletes the users GitHub account from his profile by setting the value of the `accountGithub` + * column to `null`. + */ +export const githubDeleteAccount = async ( + supabaseClient: SupabaseClient, + user: User, +): Promise => { + const { error: updateError } = await supabaseClient.from( + "profiles", + ).update({ + "accountGithub": null, + }).eq("id", user.id); + if (updateError) { + log("error", "Failed to update user profile", { + "user": user, + "error": updateError, + }); + return new Response( + JSON.stringify({ error: "Failed to update profile" }), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 500, + }, + ); + } + return new Response( + undefined, + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 200, + }, + ); +}; diff --git a/supabase/functions/profile-v2/index.ts b/supabase/functions/profile-v2/index.ts new file mode 100644 index 0000000..2fe4790 --- /dev/null +++ b/supabase/functions/profile-v2/index.ts @@ -0,0 +1,176 @@ +import { serve } from "std/server"; +import { createClient, SupabaseClient, User } from "@supabase/supabase-js"; + +import { corsHeaders } from "../_shared/utils/cors.ts"; +import { log } from "../_shared/utils/log.ts"; +import { IProfile } from "../_shared/models/profile.ts"; +import { + FEEDDECK_SUPABASE_ANON_KEY, + FEEDDECK_SUPABASE_SERVICE_ROLE_KEY, + FEEDDECK_SUPABASE_URL, +} from "../_shared/utils/constants.ts"; +import { githubAddAccount, githubDeleteAccount } from "./github.ts"; + +/** + * `getProfile` returns the users profile. The user profile contains information about the users subscription and the + * connected accounts. + * + * ATTENTION: We should never return the users account token. Instead we should return a boolean if the user has + * connected an account or not. + */ +const getProfile = async ( + supabaseClient: SupabaseClient, + user: User, +): Promise => { + const { data: profile, error: profileError } = await supabaseClient + .from( + "profiles", + ) + .select("*").eq( + "id", + user.id, + ); + if (profileError || profile?.length !== 1) { + log("error", "Failed to get user profile", { + "user": user, + "error": profileError, + }); + return new Response( + JSON.stringify({ error: "Failed to get delete user" }), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 500, + }, + ); + } + + return new Response( + JSON.stringify({ + "id": (profile[0] as IProfile).id, + "tier": (profile[0] as IProfile).tier, + "subscriptionProvider": (profile[0] as IProfile).subscriptionProvider, + "accountGithub": (profile[0] as IProfile).accountGithub?.token + ? true + : false, + "createdAt": (profile[0] as IProfile).createdAt, + "updatedAt": (profile[0] as IProfile).updatedAt, + }), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 200, + }, + ); +}; + +/** + * The `profile-v2` function is the entry point for all requests which are related to the users profile. This means that + * the function can be used to get the users profile and to handle the users connected accounts. + */ +serve(async (req) => { + const { url, method } = req; + + /** + * We need to handle the preflight request for CORS as it is described in the Supabase documentation: + * https://supabase.com/docs/guides/functions/cors + */ + if (method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + /** + * Create a new Supabase client with the anonymous key and the authorization header from the request. This allows + * us to access the database as the user that is currently signed in. + */ + const userSupabaseClient = createClient( + FEEDDECK_SUPABASE_URL, + FEEDDECK_SUPABASE_ANON_KEY, + { + global: { + headers: { Authorization: req.headers.get("Authorization")! }, + }, + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }, + ); + + /** + * Get the user from the request. If there is no user, we return an error. + */ + const { data: { user } } = await userSupabaseClient.auth.getUser(); + if (!user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 401, + }); + } + + /** + * Create a new admin client for Supabase, which is used in the following steps to access the database. This client + * is required because the user client does not have the permissions to get a users profile. + */ + const adminSupabaseClient = createClient( + FEEDDECK_SUPABASE_URL, + FEEDDECK_SUPABASE_SERVICE_ROLE_KEY, + { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }, + ); + + /** + * We use the `URLPattern` library to match the request url to the different endpoints of the function. This allows + * us to use a single function for multiple endpoints. If the request method is `POST` we also parse the request + * body. + */ + const urlPattern = new URLPattern({ pathname: "/profile-v2/:id" }); + const matchingPath = urlPattern.exec(url); + const id = matchingPath ? matchingPath.pathname.groups.id : null; + + let data = null; + if (method === "POST") { + data = await req.json(); + } + + log("debug", "Request data", { + user: user, + method: method, + id: id, + data: data ? true : false, + }); + + /** + * Now we can check the request method and the request id to determine which action we need to execute. + */ + switch (true) { + case method === "GET" && id === "getProfile": + return await getProfile(adminSupabaseClient, user); + case method === "POST" && id === "githubAddAccount": + return await githubAddAccount(adminSupabaseClient, user, data); + case method === "DELETE" && id === "githubDeleteAccount": + return await githubDeleteAccount(adminSupabaseClient, user); + default: + /** + * If the request doesn't match any of the above conditions, because it doesn't match the request method and id + * we return a `400 Bad Request` error. + */ + return new Response(JSON.stringify({ error: "Bad Request" }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 400, + }); + } + } catch (err) { + log("error", "An unexpected error occured", { "error": err.toString() }); + return new Response( + JSON.stringify({ error: "An unexpected error occured" }), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + status: 400, + }, + ); + } +});