diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..4d822c6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Poetry", + "type": "python", + "request": "launch", + "cwd": "${workspaceFolder}", + "module": "flask", + "python": "${workspaceFolder}/.venv/bin/python", + "env": { + "FLASK_APP": "app/__init__.py", + "FLASK_DEBUG": "1" + }, + "args": [ + "run", + "--no-debugger", + "--no-reload" + ], + "jinja": true, + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index e3b08ca..1546444 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,8 @@ "[python]": { "editor.defaultFormatter": "ms-python.black-formatter" }, + "deno.enable": true, "deno.enablePaths": [ - "supabase" + "./supabase/functions/" ] } \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 613a403..f3b8967 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,5 +1,6 @@ -from flask import Flask, g, render_template -from flask_misaka import Misaka +from flask import Flask, abort, g, render_template + +# from flask_misaka import Misaka from app.supabase import ( supabase, session_context_processor, @@ -13,11 +14,11 @@ from app.auth import auth from app.account import account from app.notes import notes -from app.utils import humanize_ts, resize_image +from app.utils import humanize_ts, mkdown, resize_image app = Flask(__name__, template_folder="../templates", static_folder="../static") -Misaka(app) +# Misaka(app) # Set the secret key to some random bytes. Keep this really secret! app.secret_key = b"c8af64a6a0672678800db3c5a3a8d179f386e083f559518f2528202a4b7de8f8" @@ -26,6 +27,7 @@ app.register_blueprint(account) app.register_blueprint(notes) app.jinja_env.filters["humanize"] = humanize_ts +app.jinja_env.filters["markdown"] = mkdown @app.teardown_appcontext @@ -59,6 +61,8 @@ def u(slug): def user_note(slug, note_slug): profile = get_profile_by_slug(slug) note = get_note_by_slug(note_slug) + if note.get("slug") is None: + abort(404) featured_image = None try: r = supabase.storage.from_("featured_image").get_public_url( diff --git a/app/account.py b/app/account.py index 7d18111..12be839 100644 --- a/app/account.py +++ b/app/account.py @@ -1,6 +1,8 @@ -from flask import Blueprint, render_template, flash, request, session +from flask import Blueprint, redirect, render_template, flash, request, session, url_for +from flask_wtf import FlaskForm from gotrue.errors import AuthApiError from postgrest.exceptions import APIError +from supafunc.errors import FunctionsRelayError, FunctionsHttpError from app.forms import UpdateEmailForm, UpdateForm, UpdatePasswordForm from app.supabase import get_profile_by_user, session_context_processor, supabase @@ -107,3 +109,26 @@ def update_password(): flash(err.get("message"), "error") return render_template("account/update-password.html", form=form, profile=profile) + + +@account.route("/delete", methods=["POST"]) +@login_required +def delete_account(): + form = FlaskForm() + if form.is_submitted(): + try: + r = supabase.functions.invoke("delete-account") + + if r: + flash("Your account has been successfully deleted.", "info") + supabase.auth.sign_out() + return redirect(url_for("auth.signin")) + else: + flash( + "We couldn't delete your account, please contact support.", "error" + ) + return redirect(url_for("account.home")) + except (FunctionsRelayError, FunctionsHttpError) as exception: + err = exception.to_dict() + flash(err.get("message"), "error") + return redirect(url_for("account.home")) diff --git a/app/auth.py b/app/auth.py index db8c653..f516098 100644 --- a/app/auth.py +++ b/app/auth.py @@ -56,7 +56,10 @@ def signup(): @auth.route("/signout", methods=["POST"]) def signout(): - supabase.auth.sign_out() + try: + supabase.auth.sign_out() + except AuthApiError: + None return redirect(url_for("auth.signin")) diff --git a/app/decorators.py b/app/decorators.py index afed729..6d851c0 100644 --- a/app/decorators.py +++ b/app/decorators.py @@ -2,7 +2,7 @@ from typing import Union from flask import redirect, session, url_for, request from gotrue.errors import AuthApiError, AuthRetryableError -from gotrue.types import Session +from gotrue.types import Session, User from app.supabase import get_profile_by_user, supabase diff --git a/app/notes.py b/app/notes.py index 64467f9..bd88bdc 100644 --- a/app/notes.py +++ b/app/notes.py @@ -1,8 +1,5 @@ -import base64 import io -import requests from flask import Blueprint, flash, redirect, render_template, request, url_for -from PIL import Image, ImageOps from postgrest.exceptions import APIError from app.forms import NoteForm from app.supabase import ( diff --git a/app/supabase.py b/app/supabase.py index b19ffff..4d91199 100644 --- a/app/supabase.py +++ b/app/supabase.py @@ -1,8 +1,7 @@ import os from flask import g from werkzeug.local import LocalProxy -from supabase.client import create_client, Client -from supabase.lib.client_options import ClientOptions +from supabase.client import create_client, Client, ClientOptions from app.flask_storage import FlaskSessionStorage from gotrue.errors import AuthApiError, AuthRetryableError from gotrue.types import User diff --git a/app/utils.py b/app/utils.py index d48f6d9..1a712da 100644 --- a/app/utils.py +++ b/app/utils.py @@ -4,6 +4,7 @@ import random import string from PIL import Image, ImageOps +import markdown import requests @@ -73,3 +74,7 @@ def humanize_ts(timestamp=False, fmt=False): if day_diff < 182: return str(int(day_diff / 30)) + " months ago" return datetm.strftime(fmt or "%b %d %Y") + + +def mkdown(text: str): + return markdown.markdown(text) diff --git a/package.json b/package.json index 9dad1f7..981a685 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "scripts": { "dev": "tailwindcss -w -i ./tailwind.css -o static/app.css", "build": "tailwindcss -m -i ./tailwind.css -o static/app.css", + "s:start": "supabase start", + "s:stop": "supabase stop", "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { diff --git a/static/app.css b/static/app.css index 4a19236..627c7fb 100644 --- a/static/app.css +++ b/static/app.css @@ -2736,6 +2736,10 @@ html { border-radius: 0px; } +.border { + border-width: 1px; +} + .border-b { border-bottom-width: 1px; } @@ -2750,6 +2754,11 @@ html { border-color: rgb(31 41 55 / var(--tw-border-opacity)); } +.border-red-300 { + --tw-border-opacity: 1; + border-color: rgb(252 165 165 / var(--tw-border-opacity)); +} + .bg-base-100 { --tw-bg-opacity: 1; background-color: hsl(var(--b1) / var(--tw-bg-opacity)); @@ -2780,6 +2789,26 @@ html { background-color: rgb(253 224 71 / var(--tw-bg-opacity)); } +.bg-blue-600 { + --tw-bg-opacity: 1; + background-color: rgb(37 99 235 / var(--tw-bg-opacity)); +} + +.bg-red-600 { + --tw-bg-opacity: 1; + background-color: rgb(220 38 38 / var(--tw-bg-opacity)); +} + +.bg-red-900 { + --tw-bg-opacity: 1; + background-color: rgb(127 29 29 / var(--tw-bg-opacity)); +} + +.bg-red-700 { + --tw-bg-opacity: 1; + background-color: rgb(185 28 28 / var(--tw-bg-opacity)); +} + .p-12 { padding: 3rem; } @@ -2792,6 +2821,10 @@ html { padding: 0.75rem; } +.p-4 { + padding: 1rem; +} + .px-4 { padding-left: 1rem; padding-right: 1rem; @@ -2991,6 +3024,31 @@ html { color: rgb(253 224 71 / var(--tw-text-opacity)); } +.text-blue-200 { + --tw-text-opacity: 1; + color: rgb(191 219 254 / var(--tw-text-opacity)); +} + +.text-red-400 { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity)); +} + +.text-red-800 { + --tw-text-opacity: 1; + color: rgb(153 27 27 / var(--tw-text-opacity)); +} + +.text-red-900 { + --tw-text-opacity: 1; + color: rgb(127 29 29 / var(--tw-text-opacity)); +} + +.text-red-700 { + --tw-text-opacity: 1; + color: rgb(185 28 28 / var(--tw-text-opacity)); +} + .underline { text-decoration-line: underline; } @@ -3023,6 +3081,11 @@ html { background-color: rgb(55 65 81 / var(--tw-bg-opacity)); } +.hover\:bg-red-600:hover { + --tw-bg-opacity: 1; + background-color: rgb(220 38 38 / var(--tw-bg-opacity)); +} + .hover\:text-blue-200:hover { --tw-text-opacity: 1; color: rgb(191 219 254 / var(--tw-text-opacity)); @@ -3043,6 +3106,11 @@ html { color: rgb(99 102 241 / var(--tw-text-opacity)); } +.hover\:text-gray-200:hover { + --tw-text-opacity: 1; + color: rgb(229 231 235 / var(--tw-text-opacity)); +} + .group:hover .group-hover\:inline { display: inline; } diff --git a/supabase/functions/_shared/cors.ts b/supabase/functions/_shared/cors.ts new file mode 100644 index 0000000..791c4e6 --- /dev/null +++ b/supabase/functions/_shared/cors.ts @@ -0,0 +1,5 @@ +export const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +} + \ No newline at end of file diff --git a/supabase/functions/delete-account/index.ts b/supabase/functions/delete-account/index.ts new file mode 100644 index 0000000..6e6ea5c --- /dev/null +++ b/supabase/functions/delete-account/index.ts @@ -0,0 +1,95 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts" +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.37.0" +import { corsHeaders } from "../_shared/cors.ts"; + +console.log(`Function "user-self-deletion" up and running!`) + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }) + } + + try { + const supabaseClient = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_ANON_KEY') ?? '', + // Create client with Auth context of the user that called the function. + // This way your row-level-security (RLS) policies are applied. + { + global: { + headers: { Authorization: req.headers.get('Authorization')! } + } + } + ) + + // Now we can get the session or user object + const { + data: { user }, + } = await supabaseClient.auth.getUser() + // And we can run queries in the context of our authenticated user + const { data: profile, error: userError } = await supabaseClient.from('profiles') + .select('id') + .match({ id: user?.id }) + .single() + + if (userError) { + throw userError + } + + const user_id = profile.id + const { data: list_of_files, error: storageError } = await supabaseClient + .storage + .from('featured_image') + .list(user_id) + + if (storageError) { + throw storageError + } + + const file_urls = [] + for (let i = 0; i < list_of_files.length; i++) { + file_urls.push(list_of_files[i].name) + } + + console.log("Files to delete: ", { file_urls: file_urls.map(name => `${user_id}/${name}`) }) + + // Create the admin client to delete files & user with the Admin API. + const supabaseAdmin = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' + ) + + if (file_urls.length > 0) { + const { data: fi, error: fi_error } = await supabaseAdmin + .storage + .from('featured_image') + .remove(file_urls.map(name => `${user_id}/${name}`)) + + if (fi_error) { + throw fi_error + } + } + // throw new Error(`User & files deleted user_id: ${user_id}`) + const { data: deletion_data, error: deletion_error } = await supabaseAdmin + .auth.admin + .deleteUser(user_id) + + if (deletion_error) throw deletion_error + console.log("User & files deleted user_id: " + user_id) + return new Response("User deleted: " + JSON.stringify(deletion_data, null, 2), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + }); + } catch (error) { + return Response.json({ error: error.message }, { + headers: { 'Content-Type': 'application/json' }, + status: 400 + }) + } +}) + +// To invoke: +// curl -i --location --request POST 'http://localhost:54321/functions/v1/delete_account' \ +// --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' \ +// --header 'Content-Type: application/json' \ +// --data '{"name":"Functions"}' diff --git a/templates/_helpers.html b/templates/_helpers.html index 32d8dd6..4807af4 100644 --- a/templates/_helpers.html +++ b/templates/_helpers.html @@ -17,5 +17,5 @@ {%- endmacro %} {% macro generate_url(slug) -%} - {{ request.host_url }}/u/{{ slug }} + {{ request.host_url }}u/{{ slug }} {%- endmacro %} \ No newline at end of file diff --git a/templates/account/index.html b/templates/account/index.html index 29e998e..4562bd1 100644 --- a/templates/account/index.html +++ b/templates/account/index.html @@ -1,10 +1,12 @@ {% extends "layout.html" %} +{% import "_helpers.html" as h %} {% set active_page = "account.home" %} {% block title %}Account{% endblock %} {% block content %}
Hi {{ profile.display_name or session.user.email }}, you can update your email or password from here @@ -21,5 +23,11 @@
Deleting an account is an irreversible action that permanently erases all associated data and cannot be undone.
+ +