Merge branch 'main' of gitlab.com:mstarongitlab/linstrom

This commit is contained in:
Melody Becker 2024-10-26 11:44:08 +02:00
commit 53e6418e89
64 changed files with 39325 additions and 36002 deletions

View file

@ -0,0 +1,43 @@
import { action } from '@ember/object';
import { service } from '@ember/service';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import '@simplewebauthn/browser';
import {
startAuthentication,
startRegistration,
} from '@simplewebauthn/browser';
import type AuthService from 'frontend-reactive/services/auth';
export interface PasskeySignature {
// The arguments accepted by the component
Args: {};
// Any blocks yielded by the component
Blocks: {
default: [];
};
// The element to which `...attributes` is applied in the component template
Element: null;
}
export default class Auth extends Component<PasskeySignature> {
@tracked username: string = '';
@tracked error: string | undefined;
@service declare auth: AuthService;
@action async startLogin() {
try {
this.auth.startLogin(this.username);
} catch (error: any) {
this.error = 'Error: ' + error.message;
}
}
@action async startRegistration() {
try {
this.auth.startRegistration(this.username);
} catch (error: any) {
this.error = 'Error: ' + error.message;
}
}
}

View file

@ -0,0 +1,69 @@
<div class="registration-form">
<h1 class="registration-form-username">username: {{this.args.username}}</h1>
<div class="registration-form-displayname">
<label>
Displayname
<Input
@type="text"
@value={{this.displayname}}
placeholder="Displayname"
/>
</label>
</div>
<div class="registration-form-description">
<label>
Description
<Input
@type="text"
@value={{this.description}}
placeholder="Account description"
/>
</label>
</div>
<div class="registration-form-mail">
<label>
Email
<Input @type="text" @value={{this.email}} placeholder="Email address" />
</label>
</div>
<div class="registration-form-gender">
<Util::StringArray
@list={{this.gender}}
@onNewElement={{this.genderAddedHandler}}
@onDeleteElement={{this.genderRemovedHandler}}
/>
</div>
<p>{{this.extracted}}</p>
<div class="register-form-being">
<Util::Multiselect @elements={{this.beingTypes}} />
</div>
<div class="register-form-default-post-mode">
<Util::OneOfArray
@elements={{array "Public" "Local" "Followers" "Direct"}}
@selected={{this.defaultpostmode}}
@name="default-post-mode"
@required={{true}}
/>
</div>
<div class="register-form-follow-approval">
<label>
Require approval for follow requests
<Input
@type="checkbox"
name="Follow approval"
@checked={{this.args.followapproval}}
/>
</label>
</div>
<div class="register-form-indexable">
<label>
Whether the account is indexable
<Input @type="checkbox" name="Indexable" @checked={{this.indexable}} />
</label>
</div>
<div class="register-form-custom-fields">
<Util::MapEdit @list={{this.customProperties}} />
</div>
{{! TODO: Custom fields (Two string arrays, lets go. Definitely won't be a pain to sync them up) }}
{{! TODO: Icon, Background, Banner }}
</div>

View file

@ -0,0 +1,69 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
export interface AuthRegistrationFormSignature {
// The arguments accepted by the component
Args: {
username: string;
};
// Any blocks yielded by the component
Blocks: {
default: [];
};
// The element to which `...attributes` is applied in the component template
Element: null;
}
export default class AuthRegistrationForm extends Component<AuthRegistrationFormSignature> {
@tracked displayname: string = this.args.username;
@tracked description: string = '';
@tracked gender: Array<{ value: string }> = [];
@tracked beingTypes: Array<{
name: string;
checked: boolean;
description: string;
}> = [
{
name: 'Human',
description: 'Human',
checked: true,
},
{
name: 'Cat',
description: 'Cat',
checked: false,
},
{
name: 'Fox',
description: 'Fox',
checked: false,
},
{
name: 'Dog',
description: 'Dog',
checked: false,
},
{
name: 'Robot',
description: 'Robot',
checked: false,
},
{
name: 'Doll',
description: 'Doll',
checked: false,
},
];
@tracked defaultpostmode: string = 'public';
@tracked followapproval: boolean = false;
// Actual custom properties stored in here
@tracked customProperties: Array<{ key: string; value: string }> = [];
@tracked indexable: boolean = true;
genderAddedHandler(newIndex: number) {
console.log('gender added');
}
genderRemovedHandler(removedIndex: number) {
console.log('gender removed');
}
}

View file

@ -3,6 +3,7 @@
<Note::UserHeader
@displayname="{{@note.displayname}}"
@handle="@{{@note.username}}@{{@note.server}}"
@server="{{@note.servertype}}"
/>
<Note::Content @content="{{@note.content}}" />
<div class="note-timestamps-container">

View file

@ -0,0 +1,15 @@
<div class="{{ @classes }}">
{{#if (equals @server "mastodon")}}
<Note::Formatter::Mastodon>{{ @content }}Masto</Note::Formatter::Mastodon>
{{else if (equals @server "misskey")}}
<Note::Formatter::Misskey>{{ @content }}Misskey</Note::Formatter::Misskey>
{{else if (equals @server "akoma")}}
<Note::Formatter::Akoma>{{ @content }}Akoma</Note::Formatter::Akoma>
{{else if (equals @server "linstrom")}}
<Note::Formatter::Linstrom>{{ @content }}Linstrom</Note::Formatter::Linstrom>
{{else if (equals @server "wafrn")}}
<Note::Formatter::Wafrn>{{ @content }}Wafrn</Note::Formatter::Wafrn>
{{else}}
<Note::Formatter::Linstrom>{{ @content }}Unkown:{{@server}}</Note::Formatter::Linstrom>
{{/if}}
</div>

View file

@ -0,0 +1 @@
{{yield}}

View file

@ -0,0 +1,14 @@
import Component from '@glimmer/component';
export interface NoteFormatterAkomaSignature {
// The arguments accepted by the component
Args: {};
// Any blocks yielded by the component
Blocks: {
default: [];
};
// The element to which `...attributes` is applied in the component template
Element: null;
}
export default class NoteFormatterAkoma extends Component<NoteFormatterAkomaSignature> {}

View file

@ -0,0 +1 @@
{{yield}}

View file

@ -0,0 +1,14 @@
import Component from '@glimmer/component';
export interface NoteFormatterLinstromSignature {
// The arguments accepted by the component
Args: {};
// Any blocks yielded by the component
Blocks: {
default: [];
};
// The element to which `...attributes` is applied in the component template
Element: null;
}
export default class NoteFormatterLinstrom extends Component<NoteFormatterLinstromSignature> {}

View file

@ -0,0 +1 @@
{{yield}}

View file

@ -0,0 +1,14 @@
import Component from '@glimmer/component';
export interface NoteFormatterMastodonSignature {
// The arguments accepted by the component
Args: {};
// Any blocks yielded by the component
Blocks: {
default: [];
};
// The element to which `...attributes` is applied in the component template
Element: null;
}
export default class NoteFormatterMastodon extends Component<NoteFormatterMastodonSignature> {}

View file

@ -0,0 +1 @@
{{yield}}

View file

@ -0,0 +1,14 @@
import Component from '@glimmer/component';
export interface NoteFormatterMisskeySignature {
// The arguments accepted by the component
Args: {};
// Any blocks yielded by the component
Blocks: {
default: [];
};
// The element to which `...attributes` is applied in the component template
Element: null;
}
export default class NoteFormatterMisskey extends Component<NoteFormatterMisskeySignature> {}

View file

@ -0,0 +1 @@
{{yield}}

View file

@ -0,0 +1,14 @@
import Component from '@glimmer/component';
export interface NoteFormatterWafrnSignature {
// The arguments accepted by the component
Args: {};
// Any blocks yielded by the component
Blocks: {
default: [];
};
// The element to which `...attributes` is applied in the component template
Element: null;
}
export default class NoteFormatterWafrn extends Component<NoteFormatterWafrnSignature> {}

View file

@ -3,7 +3,8 @@
<p>Pfp</p>
</div>
<div class="note-user-name-and-handle">
<p class="note-user-displayname">{{@displayname}}</p>
<Note::Formatter @classes="note-user-displayname" @content={{@displayname}} @server={{@server}}/>
<p class="note-user-displayname"></p>
<p class="note-user-handle">{{@handle}}</p>
</div>
</div>

View file

@ -1,119 +0,0 @@
import { action } from '@ember/object';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import '@simplewebauthn/browser';
import {
startAuthentication,
startRegistration,
} from '@simplewebauthn/browser';
export interface PasskeySignature {
// The arguments accepted by the component
Args: {};
// Any blocks yielded by the component
Blocks: {
default: [];
};
// The element to which `...attributes` is applied in the component template
Element: null;
}
export default class Passkey extends Component<PasskeySignature> {
@tracked username: string = '';
@tracked error: string | undefined;
@action async startLogin() {
try {
// Get login options from your server. Here, we also receive the challenge.
const response = await fetch('/webauthn/passkey/loginBegin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: this.username }),
});
// Check if the login options are ok.
if (!response.ok) {
const msg = await response.json();
throw new Error('Failed to get login options from server: ' + msg);
}
// Convert the login options to JSON.
const options = await response.json();
// This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello).
// A new assertionResponse is created. This also means that the challenge has been signed.
const assertionResponse = await startAuthentication(options.publicKey);
// Send assertionResponse back to server for verification.
const verificationResponse = await fetch(
'/webauthn/passkey/loginFinish',
{
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(assertionResponse),
},
);
const msg = await verificationResponse.json();
if (verificationResponse.ok) {
this.error = undefined;
} else {
this.error = msg;
}
} catch (error: any) {
this.error = 'Error: ' + error.message;
}
}
@action async startRegistration() {
try {
// Get registration options from your server. Here, we also receive the challenge.
const response = await fetch('/webauthn/passkey/registerBegin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: this.username }),
});
// Check if the registration options are ok.
if (!response.ok) {
const msg = await response.json();
throw new Error(
'User already exists or failed to get registration options from server: ' +
msg,
);
}
// Convert the registration options to JSON.
const options = await response.json();
console.log('registration start', options);
// This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello).
// A new attestation is created. This also means a new public-private-key pair is created.
const attestationResponse = await startRegistration(options.publicKey);
console.log('Attempting to complete registration', attestationResponse);
// Send attestationResponse back to server for verification and storage.
const verificationResponse = await fetch(
'/webauthn/passkey/registerFinish',
{
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(attestationResponse),
},
);
const msg = await verificationResponse.json();
if (verificationResponse.ok) {
this.error = undefined;
} else {
this.error = msg;
}
} catch (error: any) {
this.error = 'Error: ' + error.message;
}
}
}

View file

@ -1,5 +1,6 @@
<div class="timeline">
{{#each @notes as |note|}}
<Note @isInTimeline="true" @note={{note}}/>
<hr class="timeline-separator">
{{/each}}
</div>

View file

@ -0,0 +1,27 @@
<div class="{{@wrapper-classes}}">
<ul>
{{#each this.args.list as |element index|}}
<li>
<div class="string-array-element-wrapper">
<Input @type="text" @value={{element.key}} />
<Input @type="text" @value={{element.value}} />
<div
class="{{@remove-element-classes}}"
type="button"
{{on "click" (fn this.removeElement index)}}
>
X
</div>
</div>
</li>
{{/each}}
</ul>
<div
class="{{@add-element-classes}}"
type="button"
{{on "click" this.addElement}}
>
Add element
</div>
</div>

View file

@ -0,0 +1,36 @@
import MutableArray from '@ember/array/mutable';
import { action } from '@ember/object';
import Component from '@glimmer/component';
export interface UtilMapEditSignature {
// The arguments accepted by the component
Args: {
list: MutableArray<{ key: string; value: string }>;
prefix: string;
onNewElement: (index: number) => void;
onDeleteElement: (index: number) => void;
};
// Any blocks yielded by the component
Blocks: {
default: [];
};
// The element to which `...attributes` is applied in the component template
Element: null;
}
export default class UtilMapEdit extends Component<UtilMapEditSignature> {
@action addElement() {
MutableArray.apply(this.args.list);
this.args.list.pushObject({ key: '', value: '' });
if (this.args.onNewElement)
this.args.onNewElement(this.args.list.length - 1);
}
@action removeElement(index: number) {
MutableArray.apply(this.args.list);
//let index = this.args.list.find((elem) => elem == content)
//let index = this.listCopy.findIndex((d) => d == content)
this.args.list.removeAt(index);
if (this.args.onDeleteElement) this.args.onDeleteElement(index);
}
}

View file

@ -0,0 +1,13 @@
<div class={{@wrapper-class}}>
{{#each this.args.elements as |element|}}
<label class={{@label-class}}>
{{element.description}}
<Input
@type="checkbox"
name="{{element.name}}"
@checked={{element.checked}}
{{on "change" this.onChange}}
/>
</label>
{{/each}}
</div>

View file

@ -0,0 +1,21 @@
import { action } from '@ember/object';
import Component from '@glimmer/component';
export interface UtilMultiselectSignature {
// The arguments accepted by the component
Args: {
elements: Array<{ name: string; checked: boolean; description: string }>;
};
// Any blocks yielded by the component
Blocks: {
default: [];
};
// The element to which `...attributes` is applied in the component template
Element: null;
}
export default class UtilMultiselect extends Component<UtilMultiselectSignature> {
@action onChange() {
console.log(this.args.elements);
}
}

View file

@ -0,0 +1,12 @@
<div class="{{@wrapper-class}}">
{{#each @elements as |element|}}
<RadioButton
@value="{{element}}"
@groupValue={{@selected}}
@name={{@name}}
@required={{@required}}
>
{{element}}
</RadioButton>
{{/each}}
</div>

View file

@ -0,0 +1,29 @@
<div class="{{@wrapper-classes}}">
<ul>
{{#each this.args.list as |element index|}}
<li>
<div class="string-array-element-wrapper">
<Input
@type="text"
@value={{element.value}}
/>
<div
class="{{@remove-element-classes}}"
type="button"
{{on "click" (fn this.removeElement index)}}
>
X
</div>
</div>
</li>
{{/each}}
</ul>
<div
class="{{@add-element-classes}}"
type="button"
{{on "click" this.addElement}}
>
Add element
</div>
</div>

View file

@ -0,0 +1,56 @@
import MutableArray from '@ember/array/mutable';
import { action } from '@ember/object';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
export interface UtilStringArraySignature {
// The arguments accepted by the component
Args: {
list: MutableArray<{ value: string }>;
prefix: string;
onNewElement: (index: number) => void;
onDeleteElement: (index: number) => void;
};
// Any blocks yielded by the component
Blocks: {
default: [];
};
// The element to which `...attributes` is applied in the component template
Element: null;
}
export default class UtilStringArray extends Component<UtilStringArraySignature> {
@action addElement() {
MutableArray.apply(this.args.list);
this.args.list.pushObject({ value: '' });
if (this.args.onNewElement)
this.args.onNewElement(this.args.list.length - 1);
}
@action removeElement(index: number) {
MutableArray.apply(this.args.list);
//let index = this.args.list.find((elem) => elem == content)
//let index = this.listCopy.findIndex((d) => d == content)
this.args.list.removeAt(index);
if (this.args.onDeleteElement) this.args.onDeleteElement(index);
}
transformArrayIntoUsable(arr: Array<string>): { [key: number]: string } {
const out: { [key: number]: string } = {};
const tmp = arr.map((elem: string, index: number) => {
out[index] = elem;
return elem;
});
return out;
}
countElemsInObj(obj: any): number {
let count = 0;
for (const prop in obj) {
if (obj.hasOwnProperty(prop)) ++count;
}
return count;
}
}

View file

@ -0,0 +1,6 @@
import { helper } from '@ember/component/helper';
export default helper(function equals(args) {
if (args.length != 2) return false;
return args[0] == args[1];
});

View file

@ -8,4 +8,5 @@ export default class Router extends EmberRouter {
Router.map(function () {
this.route('about');
this.route('registerform');
});

View file

@ -19,6 +19,7 @@ export default class ApplicationRoute extends Route {
content: 'lorem ipsum',
createdAt: Date.now() - 360000,
editedAt: Date.now() - 60000,
servertype: 'mastodon',
},
{
displayname: 'Melody',
@ -27,6 +28,7 @@ export default class ApplicationRoute extends Route {
content:
'Grapple keel reef fathom haul wind bilge rat swing the lead belay line pink. Man-of-war mizzenmast killick lookout yo-ho-ho Sail ho gabion careen sutler stern. Draught wherry lookout schooner prow hail-shot spanker Letter of Marque lateen sail strike colors.\n\nLad heave to topgallant scallywag scuppers Spanish Main poop deck spike hulk broadside. Snow take a caulk hornswaggle gaff swab quarter lugger spanker bilge provost. Man-of-war measured fer yer chains lugger cable loaded to the gunwalls prow piracy snow doubloon furl.\n\nDead men tell no tales jib chase guns gunwalls Gold Road smartly nipperkin topsail bilge water Pirate Round. Gaff gunwalls bilged on her anchor bilge water scourge of the seven seas parley ho sheet chase guns squiffy. Scuppers fathom ho quarter gally heave to yardarm coxswain red ensign pink.',
createdAt: Date.now() - 3600,
servertype: 'linstrom',
},
{
displayname: 'alice',
@ -35,6 +37,7 @@ export default class ApplicationRoute extends Route {
content: 'lorem ipsum',
createdAt: Date.now() - 360000,
editedAt: Date.now() - 60000,
servertype: 'wafrn',
},
{
displayname: 'Melody',
@ -43,6 +46,7 @@ export default class ApplicationRoute extends Route {
content:
'Grapple keel reef fathom haul wind bilge rat swing the lead belay line pink. Man-of-war mizzenmast killick lookout yo-ho-ho Sail ho gabion careen sutler stern. Draught wherry lookout schooner prow hail-shot spanker Letter of Marque lateen sail strike colors.\n\nLad heave to topgallant scallywag scuppers Spanish Main poop deck spike hulk broadside. Snow take a caulk hornswaggle gaff swab quarter lugger spanker bilge provost. Man-of-war measured fer yer chains lugger cable loaded to the gunwalls prow piracy snow doubloon furl.\n\nDead men tell no tales jib chase guns gunwalls Gold Road smartly nipperkin topsail bilge water Pirate Round. Gaff gunwalls bilged on her anchor bilge water scourge of the seven seas parley ho sheet chase guns squiffy. Scuppers fathom ho quarter gally heave to yardarm coxswain red ensign pink.',
createdAt: Date.now() - 3600,
servertype: 'unknown',
},
],
};

View file

@ -0,0 +1,9 @@
import Route from '@ember/routing/route';
export default class RegisterFormRoute extends Route {
async model() {
return {
list: [{ value: 'one' }, { value: 'two' }],
};
}
}

View file

@ -0,0 +1,122 @@
import Service from '@ember/service';
import {
startAuthentication,
startRegistration,
} from '@simplewebauthn/browser';
export default class AuthService extends Service {
async startLogin(username: string) {
// Get login options from your server. Here, we also receive the challenge.
const response = await fetch('/webauthn/passkey/loginBegin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username }),
});
// Check if the login options are ok.
if (!response.ok) {
const msg = await response.json();
throw new Error('Failed to get login options from server: ' + msg);
}
// Convert the login options to JSON.
const options = await response.json();
// This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello).
// A new assertionResponse is created. This also means that the challenge has been signed.
const assertionResponse = await startAuthentication(options.publicKey);
// Send assertionResponse back to server for verification.
const verificationResponse = await fetch('/webauthn/passkey/loginFinish', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(assertionResponse),
});
const msg = await verificationResponse.json();
if (verificationResponse.ok) {
return;
} else {
throw new Error(
'Bad response code: ' +
verificationResponse.status +
'. Content: ' +
msg,
);
}
}
async startRegistration(username: string) {
// Get registration options from your server. Here, we also receive the challenge.
const response = await fetch('/webauthn/passkey/registerBegin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username }),
});
// Check if the registration options are ok.
if (!response.ok) {
const msg = await response.json();
throw new Error(
'User already exists or failed to get registration options from server: ' +
msg,
);
}
// Convert the registration options to JSON.
const options = await response.json();
console.log('registration start', options);
// This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello).
// A new attestation is created. This also means a new public-private-key pair is created.
const attestationResponse = await startRegistration(options.publicKey);
console.log('Attempting to complete registration', attestationResponse);
// Send attestationResponse back to server for verification and storage.
const verificationResponse = await fetch(
'/webauthn/passkey/registerFinish',
{
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(attestationResponse),
},
);
const msg = await verificationResponse.json();
if (verificationResponse.ok) {
return;
} else {
throw new Error(
'Bad response code: ' +
verificationResponse.status +
'. Content: ' +
msg,
);
}
}
// Check if the client is currently logged in
// Happens via caling an endpoint that's only available if logged in but doesn't return anything other than "ok"
async getLoginState(): Promise<boolean> {
try {
const response = await fetch('/api/test-auth');
return response.status >= 200 && response.status < 300;
} catch (error: any) {
return false;
}
}
}
// Don't remove this declaration: this is what enables TypeScript to resolve
// this service using `Owner.lookup('service:auth')`, as well
// as to check when you pass the service name as an argument to the decorator,
// like `@service('auth') declare altName: AuthService;`.
declare module '@ember/service' {
interface Registry {
auth: AuthService;
}
}

View file

@ -1,13 +0,0 @@
import Service from '@ember/service'
export default class AuthenticationService extends Service {}
// Don't remove this declaration: this is what enables TypeScript to resolve
// this service using `Owner.lookup('service:authentication')`, as well
// as to check when you pass the service name as an argument to the decorator,
// like `@service('authentication') declare altName: AuthenticationService;`.
declare module '@ember/service' {
interface Registry {
authentication: AuthenticationService
}
}

View file

@ -1,7 +1,11 @@
/* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */
/* @import url("debug.css"); */
@import url("fonts.css");
@import url("colors.css");
@import url("notes.css");
@import url("util.css");
@import url("svgs.css");
@import url("timeline.css");
@import url("auth.css");
@import url("stringArray.css");

View file

View file

@ -140,4 +140,5 @@
:root {
background-color: var(--background);
color: var(--text);
}

View file

@ -0,0 +1,3 @@
* {
border: red 1px dashed;
}

View file

@ -6,12 +6,12 @@
height: fit-content;
/* align-items: center; */
border: 1px dashed red;
/* max-width: 50em; */
padding: 0.5em;
background-color: var(--background-100);
color: var(--text);
border-radius: 1em;
}
.note-user-header {
@ -23,7 +23,6 @@
}
.note-user-pfp {
border: 1px dashed red;
width: 3em;
height: 3em;
padding-bottom: 0.1em;
@ -44,7 +43,7 @@
}
.note-user-handle {
/* font-size: 85%; */
font-size: 0.8em;
color: #555;
margin: 0;
}
@ -55,7 +54,7 @@
}
.note-timestamp {
/* font-size: 0.8em; */
font-size: 0.8em;
margin: -0.1em;
color: var(--text-700);
}
@ -65,11 +64,9 @@
flex-direction: column;
width: fit-content;
align-items: center;
border: 1px dashed red;
}
.note-content-text {
border: 1px dashed red;
margin-top: -0.08em;
margin-bottom: -0.08em;
padding: 0.2em;
@ -80,7 +77,7 @@
margin-bottom: 0.3em;
cursor: pointer;
background-color: var(--secondary-300);
padding: 0.1em 0.3em 0.2em;
padding: 0.1em 0.3em;
border-radius: 8px;
border: 1px solid #aaa;
}
@ -93,7 +90,7 @@
display: flex;
flex-direction: row;
align-items: center;
border-top: 1px solid black;
border-top: 1px solid var(--text-300);
padding-top: 0.5em;
}

View file

@ -0,0 +1,4 @@
.string-array-element-wrapper {
display: flex;
flex-direction: row;
}

View file

@ -5,3 +5,7 @@
scroll-behavior: smooth;
overflow-y: scroll;
}
.timeline-separator {
color: var(--text-400);
}

View file

@ -1,4 +1,4 @@
{{page-title "FrontendReactive"}}
{{outlet}}
<Timeline @notes={{@model.notes}} />
{{outlet}}{{!----}}
{{!--<Timeline @notes={{@model.notes}} />--}}

View file

@ -0,0 +1,2 @@
{{page-title "RegisterForm"}}
<Auth::RegistrationForm @username="bob" />

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -77,6 +77,7 @@
"ember-cli-inject-live-reload": "^2.1.0",
"ember-cli-sri": "^2.1.1",
"ember-cli-terser": "^4.0.2",
"ember-composable-helpers": "^5.0.0",
"ember-data": "~5.3.8",
"ember-fetch": "^8.1.2",
"ember-infinity": "^3.0.0",
@ -86,7 +87,9 @@
"ember-moment": "^10.0.1",
"ember-page-title": "^8.2.3",
"ember-qunit": "^8.1.0",
"ember-radio-button": "^3.0.0-beta.1",
"ember-resolver": "^11.0.1",
"ember-select-light": "^2.0.5",
"ember-simple-auth": "^6.1.0",
"ember-source": "~5.11.0",
"ember-template-lint": "^5.13.0",

View file

@ -0,0 +1,26 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'frontend-reactive/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | auth/registration-form', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<Auth::RegistrationForm />`);
assert.dom().hasText('');
// Template block usage:
await render(hbs`
<Auth::RegistrationForm>
template block text
</Auth::RegistrationForm>
`);
assert.dom().hasText('template block text');
});
});

View file

@ -0,0 +1,26 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'frontend-reactive/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | note/formatter', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<Note::Formatter />`);
assert.dom().hasText('');
// Template block usage:
await render(hbs`
<Note::Formatter>
template block text
</Note::Formatter>
`);
assert.dom().hasText('template block text');
});
});

View file

@ -0,0 +1,26 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'frontend-reactive/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | note/formatter/akoma', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<Note::Formatter::Akoma />`);
assert.dom().hasText('');
// Template block usage:
await render(hbs`
<Note::Formatter::Akoma>
template block text
</Note::Formatter::Akoma>
`);
assert.dom().hasText('template block text');
});
});

View file

@ -0,0 +1,26 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'frontend-reactive/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | note/formatter/linstrom', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<Note::Formatter::Linstrom />`);
assert.dom().hasText('');
// Template block usage:
await render(hbs`
<Note::Formatter::Linstrom>
template block text
</Note::Formatter::Linstrom>
`);
assert.dom().hasText('template block text');
});
});

View file

@ -0,0 +1,26 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'frontend-reactive/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | note/formatter/mastodon', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<Note::Formatter::Mastodon />`);
assert.dom().hasText('');
// Template block usage:
await render(hbs`
<Note::Formatter::Mastodon>
template block text
</Note::Formatter::Mastodon>
`);
assert.dom().hasText('template block text');
});
});

View file

@ -0,0 +1,26 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'frontend-reactive/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | note/formatter/misskey', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<Note::Formatter::Misskey />`);
assert.dom().hasText('');
// Template block usage:
await render(hbs`
<Note::Formatter::Misskey>
template block text
</Note::Formatter::Misskey>
`);
assert.dom().hasText('template block text');
});
});

View file

@ -0,0 +1,26 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'frontend-reactive/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | note/formatter/wafrn', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<Note::Formatter::Wafrn />`);
assert.dom().hasText('');
// Template block usage:
await render(hbs`
<Note::Formatter::Wafrn>
template block text
</Note::Formatter::Wafrn>
`);
assert.dom().hasText('template block text');
});
});

View file

@ -0,0 +1,26 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'frontend-reactive/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | util/map-edit', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<Util::MapEdit />`);
assert.dom().hasText('');
// Template block usage:
await render(hbs`
<Util::MapEdit>
template block text
</Util::MapEdit>
`);
assert.dom().hasText('template block text');
});
});

View file

@ -0,0 +1,26 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'frontend-reactive/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | util/multiselect', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<Util::Multiselect />`);
assert.dom().hasText('');
// Template block usage:
await render(hbs`
<Util::Multiselect>
template block text
</Util::Multiselect>
`);
assert.dom().hasText('template block text');
});
});

View file

@ -0,0 +1,26 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'frontend-reactive/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | util/one-of-array', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<Util::OneOfArray />`);
assert.dom().hasText('');
// Template block usage:
await render(hbs`
<Util::OneOfArray>
template block text
</Util::OneOfArray>
`);
assert.dom().hasText('template block text');
});
});

View file

@ -0,0 +1,26 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'frontend-reactive/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | util/string-array', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<Util::StringArray />`);
assert.dom().hasText('');
// Template block usage:
await render(hbs`
<Util::StringArray>
template block text
</Util::StringArray>
`);
assert.dom().hasText('template block text');
});
});

View file

@ -0,0 +1,17 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'frontend-reactive/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Helper | bindingArrayElement', function (hooks) {
setupRenderingTest(hooks);
// TODO: Replace this with your real tests.
test('it renders', async function (assert) {
this.set('inputValue', '1234');
await render(hbs`{{binding-array-element this.inputValue}}`);
assert.dom().hasText('1234');
});
});

View file

@ -0,0 +1,17 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'frontend-reactive/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Helper | countProperties', function (hooks) {
setupRenderingTest(hooks);
// TODO: Replace this with your real tests.
test('it renders', async function (assert) {
this.set('inputValue', '1234');
await render(hbs`{{count-properties this.inputValue}}`);
assert.dom().hasText('1234');
});
});

View file

@ -0,0 +1,17 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'frontend-reactive/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Helper | equals', function (hooks) {
setupRenderingTest(hooks);
// TODO: Replace this with your real tests.
test('it renders', async function (assert) {
this.set('inputValue', '1234');
await render(hbs`{{equals this.inputValue}}`);
assert.dom().hasText('1234');
});
});

View file

@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'frontend-reactive/tests/helpers';
module('Unit | Route | registerForm', function (hooks) {
setupTest(hooks);
test('it exists', function (assert) {
const route = this.owner.lookup('route:register-form');
assert.ok(route);
});
});

View file

@ -0,0 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from 'frontend-reactive/tests/helpers';
module('Unit | Service | auth', function (hooks) {
setupTest(hooks);
// TODO: Replace this with your real tests.
test('it exists', function (assert) {
const service = this.owner.lookup('service:auth');
assert.ok(service);
});
});

View file

@ -15,4 +15,7 @@ const (
HttpErrIdDbFailure
HttpErrIdNotAuthenticated
HttpErrIdJsonMarshalFail
HttpErrIdBadRequest
HttpErrIdAlreadyExists
HttpErrIdNotFound
)

View file

@ -0,0 +1,233 @@
package server
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"github.com/rs/zerolog/hlog"
"gitlab.com/mstarongitlab/goutils/other"
"gitlab.com/mstarongitlab/linstrom/storage"
)
func forceCorrectPasskeyAuthFlowMiddleware(
handler http.Handler,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log := hlog.FromRequest(r)
// Don't fuck with the request if not intended for starting to register or login
if strings.HasSuffix(r.URL.Path, "loginFinish") {
log.Debug().Msg("Request to finish login method, doing nothing")
handler.ServeHTTP(w, r)
return
} else if strings.HasSuffix(r.URL.Path, "registerFinish") {
handler.ServeHTTP(w, r)
// Force unset session cookie here
w.Header().Del("Set-Cookie")
http.SetCookie(w, &http.Cookie{
Name: "sid",
Value: "",
Path: "",
MaxAge: 0,
Expires: time.UnixMilli(0),
})
return
} else if strings.HasSuffix(r.URL.Path, "loginBegin") {
fuckWithLoginRequest(w, r, handler)
} else if strings.HasSuffix(r.URL.Path, "registerBegin") {
fuckWithRegisterRequest(w, r, handler)
}
})
}
func fuckWithRegisterRequest(
w http.ResponseWriter,
r *http.Request,
nextHandler http.Handler,
) {
log := hlog.FromRequest(r)
log.Debug().Msg("Messing with register start request")
store := StorageFromRequest(w, r)
if store == nil {
return
}
cookie, cookieErr := r.Cookie("sid")
var username struct {
Username string `json:"username"`
}
body, _ := io.ReadAll(r.Body)
log.Debug().Bytes("body", body).Msg("Body of auth begin request")
err := json.Unmarshal(body, &username)
if err != nil {
other.HttpErr(w, HttpErrIdBadRequest, "Not a username json object", http.StatusBadRequest)
return
}
if cookieErr == nil {
// Already authenticated, overwrite username to logged in account's name
// Get session from cookie
log.Debug().Msg("Session token exists, force overwriting username of register request")
session, ok := store.GetSession(cookie.Value)
if !ok {
log.Error().Str("session-id", cookie.Value).Msg("Passkey session missing")
other.HttpErr(
w,
HttpErrIdDbFailure,
"Passkey session missing",
http.StatusInternalServerError,
)
return
}
acc, err := store.FindAccountByPasskeyId(session.UserID)
// Assume account must exist if a session for it exists
if err != nil {
log.Error().Err(err).Msg("Failed to get account from passkey id from session")
other.HttpErr(
w,
HttpErrIdDbFailure,
"Failed to get authenticated account",
http.StatusInternalServerError,
)
return
}
// Replace whatever username may be given with username of logged in account
newBody := strings.ReplaceAll(string(body), username.Username, acc.Username)
// Assign to request
r.Body = io.NopCloser(strings.NewReader(newBody))
r.ContentLength = int64(len(newBody))
// And pass on
nextHandler.ServeHTTP(w, r)
} else {
// Not authenticated, ensure that no existing name is registered with
_, err = store.FindLocalAccountByUsername(username.Username)
switch err {
case nil:
// No error while getting account means account exists, refuse access
log.Info().
Str("username", username.Username).
Msg("Account with same name already exists, preventing login")
other.HttpErr(
w,
HttpErrIdAlreadyExists,
"Account with that name already exists",
http.StatusBadRequest,
)
case storage.ErrEntryNotFound:
// Didn't find account with that name, give access
log.Debug().
Str("username", username.Username).
Msg("No account with this username exists yet, passing through")
// Copy original body since previous reader hit EOF
r.Body = io.NopCloser(strings.NewReader(string(body)))
r.ContentLength = int64(len(body))
nextHandler.ServeHTTP(w, r)
default:
// Some other error, log it and return appropriate message
log.Error().
Err(err).
Str("username", username.Username).
Msg("Failed to check if account with username already exists")
other.HttpErr(
w,
HttpErrIdDbFailure,
"Failed to check if account with that name already exists",
http.StatusInternalServerError,
)
}
}
}
func fuckWithLoginRequest(
w http.ResponseWriter,
r *http.Request,
nextHandler http.Handler,
) {
log := hlog.FromRequest(r)
log.Debug().Msg("Messing with login start request")
store := StorageFromRequest(w, r)
if store == nil {
return
}
cookie, cookieErr := r.Cookie("sid")
var username struct {
Username string `json:"username"`
}
// Force ignore cookie for now
_ = cookieErr
var err error = errors.New("placeholder")
if err == nil {
// Someone is logged in, overwrite username with logged in account's one
body, _ := io.ReadAll(r.Body)
log.Debug().Bytes("body", body).Msg("Body of auth begin request")
err := json.Unmarshal(body, &username)
if err != nil {
other.HttpErr(
w,
HttpErrIdBadRequest,
"Not a username json object",
http.StatusBadRequest,
)
return
}
session, ok := store.GetSession(cookie.Value)
if !ok {
log.Error().Str("session-id", cookie.Value).Msg("Passkey session missing")
other.HttpErr(
w,
HttpErrIdDbFailure,
"Passkey session missing",
http.StatusInternalServerError,
)
return
}
acc, err := store.FindAccountByPasskeyId(session.UserID)
// Assume account must exist if a session for it exists
if err != nil {
log.Error().Err(err).Msg("Failed to get account from passkey id from session")
other.HttpErr(
w,
HttpErrIdDbFailure,
"Failed to get authenticated account",
http.StatusInternalServerError,
)
return
}
// Replace whatever username may be given with username of logged in account
newBody := strings.ReplaceAll(string(body), username.Username, acc.Username)
// Assign to request
r.Body = io.NopCloser(strings.NewReader(newBody))
r.ContentLength = int64(len(newBody))
// And pass on
nextHandler.ServeHTTP(w, r)
} else {
// No one logged in, check if user exists to prevent creating a bugged account
body, _ := io.ReadAll(r.Body)
log.Debug().Bytes("body", body).Msg("Body of auth begin request")
err := json.Unmarshal(body, &username)
if err != nil {
other.HttpErr(w, HttpErrIdBadRequest, "Not a username json object", http.StatusBadRequest)
return
}
_, err = store.FindLocalAccountByUsername(username.Username)
switch err {
case nil:
// All good, account exists, keep going
// Do nothing in this branch
case storage.ErrEntryNotFound:
// Account doesn't exist, catch it
other.HttpErr(w, HttpErrIdNotFound, "Username not found", http.StatusNotFound)
return
default:
// catch db failures
log.Error().Err(err).Str("username", username.Username).Msg("Db failure while getting account")
other.HttpErr(w, HttpErrIdDbFailure, "Failed to check for account in db", http.StatusInternalServerError)
return
}
// Restore body as new reader of the same content
r.Body = io.NopCloser(strings.NewReader(string(body)))
nextHandler.ServeHTTP(w, r)
}
}

View file

@ -28,7 +28,13 @@ func NewServer(store *storage.Storage, pkey *passkey.Passkey, reactiveFS, static
func buildRootHandler(pkey *passkey.Passkey, reactiveFS, staticFS fs.FS) http.Handler {
mux := http.NewServeMux()
pkey.MountRoutes(mux, "/webauthn/")
mux.Handle(
"/webauthn/",
http.StripPrefix(
"/webauthn",
forceCorrectPasskeyAuthFlowMiddleware(buildPasskeyAuthRouter(pkey)),
),
)
mux.Handle("/", setupFrontendRouter(reactiveFS, staticFS))
mux.Handle("/pk/", http.StripPrefix("/pk", http.FileServer(http.Dir("pk-auth"))))
mux.HandleFunc("/alive", isAliveHandler)
@ -58,6 +64,12 @@ func buildRootHandler(pkey *passkey.Passkey, reactiveFS, staticFS fs.FS) http.Ha
return mux
}
func buildPasskeyAuthRouter(pkey *passkey.Passkey) http.Handler {
router := http.NewServeMux()
pkey.MountRoutes(router, "/")
return router
}
func (s *Server) Start(addr string) error {
log.Info().Str("addr", addr).Msg("Starting server")
return http.ListenAndServe(addr, s.router)