mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 05:49:16 +01:00
refactor(api): ♻️ Rewrite full authentication code to go OpenID-only
This commit is contained in:
parent
4eae4cd062
commit
1bfc5fb013
39 changed files with 3076 additions and 2009 deletions
|
|
@ -169,8 +169,8 @@ export const auth = <AuthRequired extends boolean>(options: {
|
|||
|
||||
const auth: AuthData = {
|
||||
token,
|
||||
application: token?.data.application
|
||||
? new Application(token?.data.application)
|
||||
application: token?.data.client
|
||||
? new Application(token?.data.client)
|
||||
: null,
|
||||
user: (await token?.getUser()) ?? null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,13 +12,13 @@ import {
|
|||
} from "drizzle-orm";
|
||||
import type { z } from "zod/v4";
|
||||
import { db } from "../tables/db.ts";
|
||||
import { Applications } from "../tables/schema.ts";
|
||||
import { Clients } from "../tables/schema.ts";
|
||||
import { BaseInterface } from "./base.ts";
|
||||
import { Token } from "./token.ts";
|
||||
|
||||
type ApplicationType = InferSelectModel<typeof Applications>;
|
||||
type ApplicationType = InferSelectModel<typeof Clients>;
|
||||
|
||||
export class Application extends BaseInterface<typeof Applications> {
|
||||
export class Application extends BaseInterface<typeof Clients> {
|
||||
public static $type: ApplicationType;
|
||||
|
||||
public async reload(): Promise<void> {
|
||||
|
|
@ -36,18 +36,18 @@ export class Application extends BaseInterface<typeof Applications> {
|
|||
return null;
|
||||
}
|
||||
|
||||
return await Application.fromSql(eq(Applications.id, id));
|
||||
return await Application.fromSql(eq(Clients.id, id));
|
||||
}
|
||||
|
||||
public static async fromIds(ids: string[]): Promise<Application[]> {
|
||||
return await Application.manyFromSql(inArray(Applications.id, ids));
|
||||
return await Application.manyFromSql(inArray(Clients.id, ids));
|
||||
}
|
||||
|
||||
public static async fromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Applications.id),
|
||||
orderBy: SQL<unknown> | undefined = desc(Clients.id),
|
||||
): Promise<Application | null> {
|
||||
const found = await db.query.Applications.findFirst({
|
||||
const found = await db.query.Clients.findFirst({
|
||||
where: sql,
|
||||
orderBy,
|
||||
});
|
||||
|
|
@ -60,12 +60,12 @@ export class Application extends BaseInterface<typeof Applications> {
|
|||
|
||||
public static async manyFromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Applications.id),
|
||||
orderBy: SQL<unknown> | undefined = desc(Clients.id),
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
extra?: Parameters<typeof db.query.Applications.findMany>[0],
|
||||
extra?: Parameters<typeof db.query.Clients.findMany>[0],
|
||||
): Promise<Application[]> {
|
||||
const found = await db.query.Applications.findMany({
|
||||
const found = await db.query.Clients.findMany({
|
||||
where: sql,
|
||||
orderBy,
|
||||
limit,
|
||||
|
|
@ -81,22 +81,20 @@ export class Application extends BaseInterface<typeof Applications> {
|
|||
): Promise<Application | null> {
|
||||
const result = await Token.fromAccessToken(token);
|
||||
|
||||
return result?.data.application
|
||||
? new Application(result.data.application)
|
||||
: null;
|
||||
return result?.data.client ? new Application(result.data.client) : null;
|
||||
}
|
||||
|
||||
public static fromClientId(clientId: string): Promise<Application | null> {
|
||||
return Application.fromSql(eq(Applications.clientId, clientId));
|
||||
return Application.fromSql(eq(Clients.id, clientId));
|
||||
}
|
||||
|
||||
public async update(
|
||||
newApplication: Partial<ApplicationType>,
|
||||
): Promise<ApplicationType> {
|
||||
await db
|
||||
.update(Applications)
|
||||
.update(Clients)
|
||||
.set(newApplication)
|
||||
.where(eq(Applications.id, this.id));
|
||||
.where(eq(Clients.id, this.id));
|
||||
|
||||
const updated = await Application.fromId(this.data.id);
|
||||
|
||||
|
|
@ -114,18 +112,16 @@ export class Application extends BaseInterface<typeof Applications> {
|
|||
|
||||
public async delete(ids?: string[]): Promise<void> {
|
||||
if (Array.isArray(ids)) {
|
||||
await db.delete(Applications).where(inArray(Applications.id, ids));
|
||||
await db.delete(Clients).where(inArray(Clients.id, ids));
|
||||
} else {
|
||||
await db.delete(Applications).where(eq(Applications.id, this.id));
|
||||
await db.delete(Clients).where(eq(Clients.id, this.id));
|
||||
}
|
||||
}
|
||||
|
||||
public static async insert(
|
||||
data: InferInsertModel<typeof Applications>,
|
||||
data: InferInsertModel<typeof Clients>,
|
||||
): Promise<Application> {
|
||||
const inserted = (
|
||||
await db.insert(Applications).values(data).returning()
|
||||
)[0];
|
||||
const inserted = (await db.insert(Clients).values(data).returning())[0];
|
||||
|
||||
const application = await Application.fromId(inserted.id);
|
||||
|
||||
|
|
@ -144,9 +140,9 @@ export class Application extends BaseInterface<typeof Applications> {
|
|||
return {
|
||||
name: this.data.name,
|
||||
website: this.data.website,
|
||||
scopes: this.data.scopes.split(" "),
|
||||
redirect_uri: this.data.redirectUri,
|
||||
redirect_uris: this.data.redirectUri.split("\n"),
|
||||
scopes: this.data.scopes,
|
||||
redirect_uri: this.data.redirectUris.join(" "),
|
||||
redirect_uris: this.data.redirectUris,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -154,12 +150,12 @@ export class Application extends BaseInterface<typeof Applications> {
|
|||
return {
|
||||
name: this.data.name,
|
||||
website: this.data.website,
|
||||
client_id: this.data.clientId,
|
||||
client_id: this.data.id,
|
||||
client_secret: this.data.secret,
|
||||
client_secret_expires_at: "0",
|
||||
scopes: this.data.scopes.split(" "),
|
||||
redirect_uri: this.data.redirectUri,
|
||||
redirect_uris: this.data.redirectUri.split("\n"),
|
||||
scopes: this.data.scopes,
|
||||
redirect_uri: this.data.redirectUris.join(" "),
|
||||
redirect_uris: this.data.redirectUris,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,4 +12,4 @@ export { Relationship } from "./relationship.ts";
|
|||
export { Role } from "./role.ts";
|
||||
export { Timeline } from "./timeline.ts";
|
||||
export { Token } from "./token.ts";
|
||||
export { User } from "./user.ts";
|
||||
export { transformOutputToUserWithRelations, User } from "./user.ts";
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { BaseInterface } from "./base.ts";
|
|||
import { User } from "./user.ts";
|
||||
|
||||
type TokenType = InferSelectModel<typeof Tokens> & {
|
||||
application: typeof Application.$type | null;
|
||||
client: typeof Application.$type;
|
||||
};
|
||||
|
||||
export class Token extends BaseInterface<typeof Tokens, TokenType> {
|
||||
|
|
@ -51,7 +51,7 @@ export class Token extends BaseInterface<typeof Tokens, TokenType> {
|
|||
where: sql,
|
||||
orderBy,
|
||||
with: {
|
||||
application: true,
|
||||
client: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ export class Token extends BaseInterface<typeof Tokens, TokenType> {
|
|||
limit,
|
||||
offset,
|
||||
with: {
|
||||
application: true,
|
||||
client: true,
|
||||
...extra?.with,
|
||||
},
|
||||
});
|
||||
|
|
@ -159,7 +159,7 @@ export class Token extends BaseInterface<typeof Tokens, TokenType> {
|
|||
return {
|
||||
access_token: this.data.accessToken,
|
||||
token_type: "Bearer",
|
||||
scope: this.data.scope,
|
||||
scope: this.data.scopes.join(" "),
|
||||
created_at: Math.floor(
|
||||
new Date(this.data.createdAt).getTime() / 1000,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export const userRelations = {
|
|||
},
|
||||
} as const;
|
||||
|
||||
// TODO: Remove this function and use what drizzle outputs directly instead of transforming it
|
||||
export const transformOutputToUserWithRelations = (
|
||||
user: Omit<InferSelectModel<typeof Users>, "endpoints"> & {
|
||||
followerCount: unknown;
|
||||
|
|
@ -525,15 +526,15 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
providers: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
url: ProxiableUrl;
|
||||
icon?: ProxiableUrl;
|
||||
}[],
|
||||
): Promise<
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
icon?: string | undefined;
|
||||
url: ProxiableUrl;
|
||||
icon?: ProxiableUrl;
|
||||
server_id: string;
|
||||
}[]
|
||||
> {
|
||||
|
|
@ -556,7 +557,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
|||
id: issuer.id,
|
||||
name: issuer.name,
|
||||
url: issuer.url,
|
||||
icon: issuer.icon?.proxied,
|
||||
icon: issuer.icon,
|
||||
server_id: account.serverId,
|
||||
};
|
||||
})
|
||||
|
|
|
|||
46
packages/kit/tables/migrations/0051_stiff_morbius.sql
Normal file
46
packages/kit/tables/migrations/0051_stiff_morbius.sql
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
CREATE TABLE "AuthorizationCodes" (
|
||||
"code" text PRIMARY KEY NOT NULL,
|
||||
"scopes" text[] DEFAULT ARRAY[]::text[] NOT NULL,
|
||||
"redirect_uri" text,
|
||||
"expires_at" timestamp(3) NOT NULL,
|
||||
"created_at" timestamp(3) DEFAULT now() NOT NULL,
|
||||
"code_challenge" text,
|
||||
"code_challenge_method" text,
|
||||
"userId" uuid NOT NULL,
|
||||
"clientId" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "Tokens" RENAME COLUMN "applicationId" TO "clientId";--> statement-breakpoint
|
||||
--ALTER TABLE "Notes" DROP CONSTRAINT "Notes_applicationId_Applications_id_fk";
|
||||
--> statement-breakpoint
|
||||
--ALTER TABLE "OpenIdLoginFlows" DROP CONSTRAINT "OpenIdLoginFlows_applicationId_Applications_id_fk";
|
||||
--> statement-breakpoint
|
||||
--ALTER TABLE "Tokens" DROP CONSTRAINT "Tokens_applicationId_Applications_id_fk";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX "Applications_client_id_index";--> statement-breakpoint
|
||||
ALTER TABLE "Applications" ADD PRIMARY KEY ("client_id");--> statement-breakpoint
|
||||
ALTER TABLE "Applications" ALTER COLUMN "scopes" SET DATA TYPE text[] USING (string_to_array("scopes", ' ')::text[]);--> statement-breakpoint
|
||||
ALTER TABLE "Applications" ALTER COLUMN "scopes" SET DEFAULT ARRAY[]::text[];--> statement-breakpoint
|
||||
ALTER TABLE "Notes" ALTER COLUMN "applicationId" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "OpenIdLoginFlows" ALTER COLUMN "applicationId" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "Applications" ADD COLUMN "redirect_uris" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "OpenIdLoginFlows" ADD COLUMN "state" text;--> statement-breakpoint
|
||||
ALTER TABLE "OpenIdLoginFlows" ADD COLUMN "client_state" text;--> statement-breakpoint
|
||||
ALTER TABLE "OpenIdLoginFlows" ADD COLUMN "client_redirect_uri" text;--> statement-breakpoint
|
||||
ALTER TABLE "OpenIdLoginFlows" ADD COLUMN "client_scopes" text[];--> statement-breakpoint
|
||||
ALTER TABLE "Tokens" ADD COLUMN "scopes" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "AuthorizationCodes" ADD CONSTRAINT "AuthorizationCodes_userId_Users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."Users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "AuthorizationCodes" ADD CONSTRAINT "AuthorizationCodes_clientId_Applications_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."Applications"("client_id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "Notes" ADD CONSTRAINT "Notes_applicationId_Applications_client_id_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."Applications"("client_id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "OpenIdLoginFlows" ADD CONSTRAINT "OpenIdLoginFlows_applicationId_Applications_client_id_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."Applications"("client_id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "Tokens" ALTER COLUMN "clientId" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "Tokens" ADD CONSTRAINT "Tokens_clientId_Applications_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."Applications"("client_id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE "Applications" DROP COLUMN "id";--> statement-breakpoint
|
||||
ALTER TABLE "Applications" DROP COLUMN "vapid_key";--> statement-breakpoint
|
||||
ALTER TABLE "Applications" DROP COLUMN "redirect_uri";--> statement-breakpoint
|
||||
ALTER TABLE "Tokens" DROP COLUMN "token_type";--> statement-breakpoint
|
||||
ALTER TABLE "Tokens" DROP COLUMN "scope";--> statement-breakpoint
|
||||
ALTER TABLE "Tokens" DROP COLUMN "code";--> statement-breakpoint
|
||||
ALTER TABLE "Tokens" DROP COLUMN "client_id";--> statement-breakpoint
|
||||
ALTER TABLE "Tokens" DROP COLUMN "redirect_uri";--> statement-breakpoint
|
||||
ALTER TABLE "Tokens" DROP COLUMN "id_token";
|
||||
2439
packages/kit/tables/migrations/meta/0051_snapshot.json
Normal file
2439
packages/kit/tables/migrations/meta/0051_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -358,6 +358,13 @@
|
|||
"when": 1746368175263,
|
||||
"tag": "0050_thick_lester",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 51,
|
||||
"version": "7",
|
||||
"when": 1755729662013,
|
||||
"tag": "0051_stiff_morbius",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
import type { z } from "zod/v4";
|
||||
|
||||
const createdAt = () =>
|
||||
// TODO: Change mode to Date
|
||||
timestamp("created_at", { precision: 3, mode: "string" })
|
||||
.defaultNow()
|
||||
.notNull();
|
||||
|
|
@ -39,7 +40,7 @@ const updatedAt = () =>
|
|||
|
||||
const uri = () => text("uri").unique();
|
||||
|
||||
const id = () => uuid("id").primaryKey().notNull();
|
||||
const id = () => uuid("id").primaryKey();
|
||||
|
||||
export const Challenges = pgTable("Challenges", {
|
||||
id: id(),
|
||||
|
|
@ -308,47 +309,41 @@ export const RelationshipsRelations = relations(Relationships, ({ one }) => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
export const Applications = pgTable(
|
||||
"Applications",
|
||||
{
|
||||
id: id(),
|
||||
name: text("name").notNull(),
|
||||
website: text("website"),
|
||||
vapidKey: text("vapid_key"),
|
||||
clientId: text("client_id").notNull(),
|
||||
secret: text("secret").notNull(),
|
||||
scopes: text("scopes").notNull(),
|
||||
redirectUri: text("redirect_uri").notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex().on(table.clientId)],
|
||||
);
|
||||
export const Clients = pgTable("Applications", {
|
||||
id: text("client_id").primaryKey(),
|
||||
secret: text("secret").notNull(),
|
||||
redirectUris: text("redirect_uris")
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`ARRAY[]::text[]`),
|
||||
scopes: text("scopes").array().notNull().default(sql`ARRAY[]::text[]`),
|
||||
name: text("name").notNull(),
|
||||
website: text("website"),
|
||||
});
|
||||
|
||||
export const ApplicationsRelations = relations(Applications, ({ many }) => ({
|
||||
export const ClientsRelations = relations(Clients, ({ many }) => ({
|
||||
tokens: many(Tokens),
|
||||
loginFlows: many(OpenIdLoginFlows),
|
||||
}));
|
||||
|
||||
export const Tokens = pgTable("Tokens", {
|
||||
id: id(),
|
||||
tokenType: text("token_type").notNull(),
|
||||
scope: text("scope").notNull(),
|
||||
scopes: text("scopes").array().notNull().default(sql`ARRAY[]::text[]`),
|
||||
accessToken: text("access_token").notNull(),
|
||||
code: text("code"),
|
||||
expiresAt: timestamp("expires_at", { precision: 3, mode: "string" }),
|
||||
createdAt: createdAt(),
|
||||
clientId: text("client_id").notNull().default(""),
|
||||
redirectUri: text("redirect_uri").notNull().default(""),
|
||||
idToken: text("id_token"),
|
||||
userId: uuid("userId")
|
||||
.references(() => Users.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
})
|
||||
.notNull(),
|
||||
applicationId: uuid("applicationId").references(() => Applications.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
}),
|
||||
clientId: text("clientId")
|
||||
.references(() => Clients.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
})
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const TokensRelations = relations(Tokens, ({ one }) => ({
|
||||
|
|
@ -356,12 +351,51 @@ export const TokensRelations = relations(Tokens, ({ one }) => ({
|
|||
fields: [Tokens.userId],
|
||||
references: [Users.id],
|
||||
}),
|
||||
application: one(Applications, {
|
||||
fields: [Tokens.applicationId],
|
||||
references: [Applications.id],
|
||||
client: one(Clients, {
|
||||
fields: [Tokens.clientId],
|
||||
references: [Clients.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const AuthorizationCodes = pgTable("AuthorizationCodes", {
|
||||
code: text("code").primaryKey(),
|
||||
scopes: text("scopes").array().notNull().default(sql`ARRAY[]::text[]`),
|
||||
redirectUri: text("redirect_uri"),
|
||||
expiresAt: timestamp("expires_at", {
|
||||
precision: 3,
|
||||
mode: "string",
|
||||
}).notNull(),
|
||||
createdAt: createdAt(),
|
||||
codeChallenge: text("code_challenge"),
|
||||
codeChallengeMethod: text("code_challenge_method"),
|
||||
userId: uuid("userId")
|
||||
.references(() => Users.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
})
|
||||
.notNull(),
|
||||
clientId: text("clientId")
|
||||
.references(() => Clients.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
})
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const AuthorizationCodesRelations = relations(
|
||||
AuthorizationCodes,
|
||||
({ one }) => ({
|
||||
user: one(Users, {
|
||||
fields: [AuthorizationCodes.userId],
|
||||
references: [Users.id],
|
||||
}),
|
||||
client: one(Clients, {
|
||||
fields: [AuthorizationCodes.clientId],
|
||||
references: [Clients.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const Medias = pgTable("Medias", {
|
||||
id: id(),
|
||||
content: jsonb("content")
|
||||
|
|
@ -460,7 +494,7 @@ export const Notes = pgTable("Notes", {
|
|||
}),
|
||||
sensitive: boolean("sensitive").notNull().default(false),
|
||||
spoilerText: text("spoiler_text").default("").notNull(),
|
||||
applicationId: uuid("applicationId").references(() => Applications.id, {
|
||||
applicationId: text("applicationId").references(() => Clients.id, {
|
||||
onDelete: "set null",
|
||||
onUpdate: "cascade",
|
||||
}),
|
||||
|
|
@ -494,9 +528,9 @@ export const NotesRelations = relations(Notes, ({ many, one }) => ({
|
|||
references: [Notes.id],
|
||||
relationName: "NoteToQuotes",
|
||||
}),
|
||||
application: one(Applications, {
|
||||
application: one(Clients, {
|
||||
fields: [Notes.applicationId],
|
||||
references: [Applications.id],
|
||||
references: [Clients.id],
|
||||
}),
|
||||
quotes: many(Notes, {
|
||||
relationName: "NoteToQuotes",
|
||||
|
|
@ -665,7 +699,11 @@ export const UsersRelations = relations(Users, ({ many, one }) => ({
|
|||
export const OpenIdLoginFlows = pgTable("OpenIdLoginFlows", {
|
||||
id: id(),
|
||||
codeVerifier: text("code_verifier").notNull(),
|
||||
applicationId: uuid("applicationId").references(() => Applications.id, {
|
||||
state: text("state"),
|
||||
clientState: text("client_state"),
|
||||
clientRedirectUri: text("client_redirect_uri"),
|
||||
clientScopes: text("client_scopes").array(),
|
||||
applicationId: text("applicationId").references(() => Clients.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
}),
|
||||
|
|
@ -675,9 +713,9 @@ export const OpenIdLoginFlows = pgTable("OpenIdLoginFlows", {
|
|||
export const OpenIdLoginFlowsRelations = relations(
|
||||
OpenIdLoginFlows,
|
||||
({ one }) => ({
|
||||
application: one(Applications, {
|
||||
application: one(Clients, {
|
||||
fields: [OpenIdLoginFlows.applicationId],
|
||||
references: [Applications.id],
|
||||
references: [Clients.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue