API: implement user account deletion
5 files changed, 94 insertions(+), 1 deletions(-)

A => api/account/middleware.go
M api/go.mod
M api/graph/schema.graphqls
M api/graph/schema.resolvers.go
M api/server.go
A => api/account/middleware.go +64 -0
@@ 0,0 1,64 @@ 
+package account
+
+import (
+	"context"
+	"database/sql"
+	"log"
+	"net/http"
+	"os"
+	"path"
+
+	"git.sr.ht/~sircmpwn/core-go/config"
+	"git.sr.ht/~sircmpwn/core-go/database"
+	work "git.sr.ht/~sircmpwn/dowork"
+)
+
+type contextKey struct {
+	name string
+}
+
+var ctxKey = &contextKey{"account"}
+
+func Middleware(queue *work.Queue) func(next http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			ctx := context.WithValue(r.Context(), ctxKey, queue)
+			r = r.WithContext(ctx)
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
+// Schedules a user account deletion.
+func Delete(ctx context.Context, userID int, username string) {
+	queue, ok := ctx.Value(ctxKey).(*work.Queue)
+	if !ok {
+		panic("No account worker for this context")
+	}
+
+	conf := config.ForContext(ctx)
+	repoStore, ok := conf.Get("hg.sr.ht", "repos")
+
+	task := work.NewTask(func(ctx context.Context) error {
+		log.Printf("Processing deletion of user account %d %s", userID, username)
+
+		userPath := path.Join(repoStore, "~"+username)
+		if err := os.RemoveAll(userPath); err != nil {
+			log.Printf("Failed to remove %s: %s", userPath, err.Error())
+		}
+
+		if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
+			_, err := tx.ExecContext(ctx, `
+				DELETE FROM "user" WHERE id = $1
+			`, userID)
+			return err
+		}); err != nil {
+			return err
+		}
+
+		log.Printf("Deletion of user account %d %s complete", userID, username)
+		return nil
+	})
+	queue.Enqueue(task)
+	log.Printf("Enqueued deletion of user account %d %s", userID, username)
+}

          
M api/go.mod +1 -0
@@ 4,6 4,7 @@ go 1.14
 
 require (
 	git.sr.ht/~sircmpwn/core-go v0.0.0-20221025082458-3e69641ef307
+	git.sr.ht/~sircmpwn/dowork v0.0.0-20210820133136-d3970e97def3
 	github.com/99designs/gqlgen v0.17.20
 	github.com/Masterminds/squirrel v1.4.0
 	github.com/google/uuid v1.0.0

          
M api/graph/schema.graphqls +11 -0
@@ 11,6 11,12 @@ access token, and are not available to c
 """
 directive @private on FIELD_DEFINITION
 
+"""
+This used to decorate fields which are for internal use, and are not
+available to normal API users.
+"""
+directive @internal on FIELD_DEFINITION
+
 enum AccessScope {
   PROFILE      @scopehelp(details: "profile information")
   REPOSITORIES @scopehelp(details: "repository metadata")

          
@@ 433,4 439,9 @@ type Mutation {
   unexpected behavior with the third-party integration.
   """
   deleteWebhook(id: Int!): WebhookSubscription!
+
+  """
+  Deletes the authenticated user's account. Internal use only.
+  """
+  deleteUser: Int!
 }

          
M api/graph/schema.resolvers.go +8 -0
@@ 24,6 24,7 @@ import (
 	corewebhooks "git.sr.ht/~sircmpwn/core-go/webhooks"
 	sq "github.com/Masterminds/squirrel"
 	"github.com/lib/pq"
+	"hg.sr.ht/~sircmpwn/hg.sr.ht/api/account"
 	"hg.sr.ht/~sircmpwn/hg.sr.ht/api/graph/api"
 	"hg.sr.ht/~sircmpwn/hg.sr.ht/api/graph/model"
 	"hg.sr.ht/~sircmpwn/hg.sr.ht/api/hgpool"

          
@@ 503,6 504,13 @@ func (r *mutationResolver) DeleteWebhook
 	return &sub, nil
 }
 
+// DeleteUser is the resolver for the deleteUser field.
+func (r *mutationResolver) DeleteUser(ctx context.Context) (int, error) {
+	user := auth.ForContext(ctx)
+	account.Delete(ctx, user.UserID, user.Username)
+	return user.UserID, nil
+}
+
 // Version is the resolver for the version field.
 func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) {
 	conf := config.ForContext(ctx)

          
M api/server.go +10 -1
@@ 10,7 10,9 @@ import (
 	"git.sr.ht/~sircmpwn/core-go/config"
 	"git.sr.ht/~sircmpwn/core-go/server"
 	"git.sr.ht/~sircmpwn/core-go/webhooks"
+	work "git.sr.ht/~sircmpwn/dowork"
 
+	"hg.sr.ht/~sircmpwn/hg.sr.ht/api/account"
 	"hg.sr.ht/~sircmpwn/hg.sr.ht/api/graph"
 	"hg.sr.ht/~sircmpwn/hg.sr.ht/api/graph/api"
 	"hg.sr.ht/~sircmpwn/hg.sr.ht/api/graph/model"

          
@@ 43,6 45,7 @@ func main() {
 
 	gqlConfig := api.Config{Resolvers: &graph.Resolver{}}
 	gqlConfig.Directives.Private = server.Private
+	gqlConfig.Directives.Internal = server.Internal
 	gqlConfig.Directives.Access = func(ctx context.Context, obj interface{},
 		next graphql.Resolver, scope model.AccessScope,
 		kind model.AccessKind) (interface{}, error) {

          
@@ 55,6 58,7 @@ func main() {
 		scopes[i] = s.String()
 	}
 
+	accountQueue := work.NewQueue("account")
 	webhooksQueue := webhooks.NewQueue(schema)
 	legacyWebhooks := webhooks.NewLegacyQueue()
 

          
@@ 64,11 68,16 @@ func main() {
 		WithDefaultMiddleware().
 		WithMiddleware(
 			loaders.Middleware,
+			account.Middleware(accountQueue),
 			webhooks.Middleware(webhooksQueue),
 			webhooks.LegacyMiddleware(legacyWebhooks),
 			hgpool.Middleware(globalPool),
 		).
 		WithSchema(schema, scopes).
-		WithQueues(webhooksQueue.Queue, legacyWebhooks.Queue).
+		WithQueues(
+			accountQueue,
+			webhooksQueue.Queue,
+			legacyWebhooks.Queue,
+		).
 		Run()
 }