From 49cce6a968a838a01857d9a9481547a3ba09f15f Mon Sep 17 00:00:00 2001 From: Jens L Date: Tue, 5 Jul 2022 23:09:41 +0200 Subject: [PATCH] stages/prompt: add basic file field (#3156) add basic file field Signed-off-by: Jens Langhammer --- .../migrations/0008_alter_prompt_type.py | 48 +++++++++++++++++++ authentik/stages/prompt/models.py | 30 +++++++++++- authentik/stages/prompt/tests.py | 15 +++++- schema.yml | 2 + web/src/flows/stages/base.ts | 20 +++++++- web/src/flows/stages/prompt/PromptStage.ts | 9 +++- web/src/pages/stages/prompt/PromptForm.ts | 6 +++ web/tsconfig.json | 1 + website/docs/flow/stages/prompt/index.md | 31 ++++++------ 9 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 authentik/stages/prompt/migrations/0008_alter_prompt_type.py diff --git a/authentik/stages/prompt/migrations/0008_alter_prompt_type.py b/authentik/stages/prompt/migrations/0008_alter_prompt_type.py new file mode 100644 index 00000000000..a97946765b3 --- /dev/null +++ b/authentik/stages/prompt/migrations/0008_alter_prompt_type.py @@ -0,0 +1,48 @@ +# Generated by Django 4.0.5 on 2022-06-26 21:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_prompt", "0007_prompt_placeholder_expression"), + ] + + operations = [ + migrations.AlterField( + model_name="prompt", + name="type", + field=models.CharField( + choices=[ + ("text", "Text: Simple Text input"), + ( + "text_read_only", + "Text (read-only): Simple Text input, but cannot be edited.", + ), + ( + "username", + "Username: Same as Text input, but checks for and prevents duplicate usernames.", + ), + ("email", "Email: Text field with Email type."), + ( + "password", + "Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical.", + ), + ("number", "Number"), + ("checkbox", "Checkbox"), + ("date", "Date"), + ("date-time", "Date Time"), + ( + "file", + "File: File upload for arbitrary files. File content will be available in flow context as data-URI", + ), + ("separator", "Separator: Static Separator Line"), + ("hidden", "Hidden: Hidden field, can be used to insert data into form."), + ("static", "Static: Static value, displayed as-is."), + ("ak-locale", "authentik: Selection of locales authentik supports"), + ], + max_length=100, + ), + ), + ] diff --git a/authentik/stages/prompt/models.py b/authentik/stages/prompt/models.py index e657ea1a7f8..de401a61606 100644 --- a/authentik/stages/prompt/models.py +++ b/authentik/stages/prompt/models.py @@ -1,11 +1,14 @@ """prompt models""" +from base64 import b64decode from typing import Any, Optional +from urllib.parse import urlparse from uuid import uuid4 from django.db import models from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ from django.views import View +from rest_framework.exceptions import ValidationError from rest_framework.fields import ( BooleanField, CharField, @@ -32,7 +35,7 @@ class FieldTypes(models.TextChoices): """Field types an Prompt can be""" - # update website/docs/flow/stages/prompt.index.md + # update website/docs/flow/stages/prompt/index.md # Simple text field TEXT = "text", _("Text: Simple Text input") @@ -61,6 +64,14 @@ class FieldTypes(models.TextChoices): DATE = "date" DATE_TIME = "date-time" + FILE = ( + "file", + _( + "File: File upload for arbitrary files. File content will be available in flow " + "context as data-URI" + ), + ) + SEPARATOR = "separator", _("Separator: Static Separator Line") HIDDEN = "hidden", _("Hidden: Hidden field, can be used to insert data into form.") STATIC = "static", _("Static: Static value, displayed as-is.") @@ -68,6 +79,21 @@ class FieldTypes(models.TextChoices): AK_LOCALE = "ak-locale", _("authentik: Selection of locales authentik supports") +class InlineFileField(CharField): + """Field for inline data-URI base64 encoded files""" + + def to_internal_value(self, data: str): + uri = urlparse(data) + if uri.scheme != "data": + raise ValidationError("Invalid scheme") + header, encoded = uri.path.split(",", 1) + _mime, _, enc = header.partition(";") + if enc != "base64": + raise ValidationError("Invalid encoding") + data = b64decode(encoded.encode()).decode() + return super().to_internal_value(data) + + class Prompt(SerializerModel): """Single Prompt, part of a prompt stage.""" @@ -134,6 +160,8 @@ def field(self, default: Optional[Any]) -> CharField: field_class = DateField if self.type == FieldTypes.DATE_TIME: field_class = DateTimeField + if self.type == FieldTypes.FILE: + field_class = InlineFileField if self.type == FieldTypes.SEPARATOR: kwargs["required"] = False diff --git a/authentik/stages/prompt/tests.py b/authentik/stages/prompt/tests.py index d998729bd41..8a393154862 100644 --- a/authentik/stages/prompt/tests.py +++ b/authentik/stages/prompt/tests.py @@ -3,7 +3,7 @@ from django.test import RequestFactory from django.urls import reverse -from rest_framework.exceptions import ErrorDetail +from rest_framework.exceptions import ErrorDetail, ValidationError from authentik.core.tests.utils import create_test_admin_user from authentik.flows.markers import StageMarker @@ -13,7 +13,7 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.lib.generators import generate_id from authentik.policies.expression.models import ExpressionPolicy -from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage +from authentik.stages.prompt.models import FieldTypes, InlineFileField, Prompt, PromptStage from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT, PromptChallengeResponse @@ -110,6 +110,17 @@ def setUp(self): self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) + def test_inline_file_field(self): + """test InlineFileField""" + with self.assertRaises(ValidationError): + InlineFileField().to_internal_value("foo") + with self.assertRaises(ValidationError): + InlineFileField().to_internal_value("data:foo/bar;foo,qwer") + self.assertEqual( + InlineFileField().to_internal_value("data:mine/type;base64,Zm9v"), + "foo", + ) + def test_render(self): """Test render of form, check if all prompts are rendered correctly""" plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) diff --git a/schema.yml b/schema.yml index 5bbc38d0e69..18470bb5ff8 100644 --- a/schema.yml +++ b/schema.yml @@ -17741,6 +17741,7 @@ paths: - date - date-time - email + - file - hidden - number - password @@ -29242,6 +29243,7 @@ components: - checkbox - date - date-time + - file - separator - hidden - static diff --git a/web/src/flows/stages/base.ts b/web/src/flows/stages/base.ts index 20f1a335989..90bbbbe4d76 100644 --- a/web/src/flows/stages/base.ts +++ b/web/src/flows/stages/base.ts @@ -12,6 +12,17 @@ export interface StageHost { readonly tenant: CurrentTenant; } +export function readFileAsync(file: Blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + resolve(reader.result); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + export class BaseStage extends LitElement { host!: StageHost; @@ -24,7 +35,14 @@ export class BaseStage extends LitElement { [key: string]: unknown; } = {}; const form = new FormData(this.shadowRoot?.querySelector("form") || undefined); - form.forEach((value, key) => (object[key] = value)); + + for await (const [key, value] of form.entries()) { + if (value instanceof Blob) { + object[key] = await readFileAsync(value); + } else { + object[key] = value; + } + } return this.host?.submit(object as unknown as Tout).then((successful) => { if (successful) { this.cleanup(); diff --git a/web/src/flows/stages/prompt/PromptStage.ts b/web/src/flows/stages/prompt/PromptStage.ts index 8af9d933943..6a7808e1649 100644 --- a/web/src/flows/stages/prompt/PromptStage.ts +++ b/web/src/flows/stages/prompt/PromptStage.ts @@ -96,6 +96,13 @@ export class PromptStage extends BaseStage`; + case PromptTypeEnum.File: + return ``; case PromptTypeEnum.Separator: return `${prompt.placeholder}`; case PromptTypeEnum.Hidden: @@ -133,7 +140,7 @@ export class PromptStage extends BaseStage${unsafeHTML(prompt.subText)}

`; } - shouldRenderInWrapper(prompt: StagePrompt): bool { + shouldRenderInWrapper(prompt: StagePrompt): boolean { // Special types that aren't rendered in a wrapper if ( prompt.type === PromptTypeEnum.Static || diff --git a/web/src/pages/stages/prompt/PromptForm.ts b/web/src/pages/stages/prompt/PromptForm.ts index b938c38ebaf..9003282a092 100644 --- a/web/src/pages/stages/prompt/PromptForm.ts +++ b/web/src/pages/stages/prompt/PromptForm.ts @@ -97,6 +97,12 @@ export class PromptForm extends ModelForm { > ${t`Date Time`} +