feat: add session creation endpoint (#18295)

This commit is contained in:
Brandon Wees 2025-05-15 13:34:33 -05:00 committed by GitHub
parent 585997d46f
commit 6117329057
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 241 additions and 50 deletions

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -5618,6 +5618,46 @@
"tags": [ "tags": [
"Sessions" "Sessions"
] ]
},
"post": {
"operationId": "createSession",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionCreateDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionCreateResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Sessions"
]
} }
}, },
"/sessions/{id}": { "/sessions/{id}": {
@ -11052,6 +11092,7 @@
"person.statistics", "person.statistics",
"person.merge", "person.merge",
"person.reassign", "person.reassign",
"session.create",
"session.read", "session.read",
"session.update", "session.update",
"session.delete", "session.delete",
@ -12038,6 +12079,57 @@
], ],
"type": "object" "type": "object"
}, },
"SessionCreateDto": {
"properties": {
"deviceOS": {
"type": "string"
},
"deviceType": {
"type": "string"
},
"duration": {
"description": "session duration, in seconds",
"minimum": 1,
"type": "number"
}
},
"type": "object"
},
"SessionCreateResponseDto": {
"properties": {
"createdAt": {
"type": "string"
},
"current": {
"type": "boolean"
},
"deviceOS": {
"type": "string"
},
"deviceType": {
"type": "string"
},
"id": {
"type": "string"
},
"token": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
},
"required": [
"createdAt",
"current",
"deviceOS",
"deviceType",
"id",
"token",
"updatedAt"
],
"type": "object"
},
"SessionResponseDto": { "SessionResponseDto": {
"properties": { "properties": {
"createdAt": { "createdAt": {

View file

@ -1078,6 +1078,21 @@ export type SessionResponseDto = {
id: string; id: string;
updatedAt: string; updatedAt: string;
}; };
export type SessionCreateDto = {
deviceOS?: string;
deviceType?: string;
/** session duration, in seconds */
duration?: number;
};
export type SessionCreateResponseDto = {
createdAt: string;
current: boolean;
deviceOS: string;
deviceType: string;
id: string;
token: string;
updatedAt: string;
};
export type SharedLinkResponseDto = { export type SharedLinkResponseDto = {
album?: AlbumResponseDto; album?: AlbumResponseDto;
allowDownload: boolean; allowDownload: boolean;
@ -2917,6 +2932,18 @@ export function getSessions(opts?: Oazapfts.RequestOpts) {
...opts ...opts
})); }));
} }
export function createSession({ sessionCreateDto }: {
sessionCreateDto: SessionCreateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: SessionCreateResponseDto;
}>("/sessions", oazapfts.json({
...opts,
method: "POST",
body: sessionCreateDto
})));
}
export function deleteSession({ id }: { export function deleteSession({ id }: {
id: string; id: string;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
@ -3678,6 +3705,7 @@ export enum Permission {
PersonStatistics = "person.statistics", PersonStatistics = "person.statistics",
PersonMerge = "person.merge", PersonMerge = "person.merge",
PersonReassign = "person.reassign", PersonReassign = "person.reassign",
SessionCreate = "session.create",
SessionRead = "session.read", SessionRead = "session.read",
SessionUpdate = "session.update", SessionUpdate = "session.update",
SessionDelete = "session.delete", SessionDelete = "session.delete",

View file

@ -1,7 +1,7 @@
import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto } from 'src/dtos/session.dto'; import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto } from 'src/dtos/session.dto';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { SessionService } from 'src/services/session.service'; import { SessionService } from 'src/services/session.service';
@ -12,6 +12,12 @@ import { UUIDParamDto } from 'src/validation';
export class SessionController { export class SessionController {
constructor(private service: SessionService) {} constructor(private service: SessionService) {}
@Post()
@Authenticated({ permission: Permission.SESSION_CREATE })
createSession(@Auth() auth: AuthDto, @Body() dto: SessionCreateDto): Promise<SessionCreateResponseDto> {
return this.service.create(auth, dto);
}
@Get() @Get()
@Authenticated({ permission: Permission.SESSION_READ }) @Authenticated({ permission: Permission.SESSION_READ })
getSessions(@Auth() auth: AuthDto): Promise<SessionResponseDto[]> { getSessions(@Auth() auth: AuthDto): Promise<SessionResponseDto[]> {

2
server/src/db.d.ts vendored
View file

@ -343,6 +343,8 @@ export interface Sessions {
deviceOS: Generated<string>; deviceOS: Generated<string>;
deviceType: Generated<string>; deviceType: Generated<string>;
id: Generated<string>; id: Generated<string>;
parentId: string | null;
expiredAt: Date | null;
token: string; token: string;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
updateId: Generated<string>; updateId: Generated<string>;

View file

@ -1,4 +1,24 @@
import { IsInt, IsPositive, IsString } from 'class-validator';
import { Session } from 'src/database'; import { Session } from 'src/database';
import { Optional } from 'src/validation';
export class SessionCreateDto {
/**
* session duration, in seconds
*/
@IsInt()
@IsPositive()
@Optional()
duration?: number;
@IsString()
@Optional()
deviceType?: string;
@IsString()
@Optional()
deviceOS?: string;
}
export class SessionResponseDto { export class SessionResponseDto {
id!: string; id!: string;
@ -9,6 +29,10 @@ export class SessionResponseDto {
deviceOS!: string; deviceOS!: string;
} }
export class SessionCreateResponseDto extends SessionResponseDto {
token!: string;
}
export const mapSession = (entity: Session, currentId?: string): SessionResponseDto => ({ export const mapSession = (entity: Session, currentId?: string): SessionResponseDto => ({
id: entity.id, id: entity.id,
createdAt: entity.createdAt.toISOString(), createdAt: entity.createdAt.toISOString(),

View file

@ -144,6 +144,7 @@ export enum Permission {
PERSON_MERGE = 'person.merge', PERSON_MERGE = 'person.merge',
PERSON_REASSIGN = 'person.reassign', PERSON_REASSIGN = 'person.reassign',
SESSION_CREATE = 'session.create',
SESSION_READ = 'session.read', SESSION_READ = 'session.read',
SESSION_UPDATE = 'session.update', SESSION_UPDATE = 'session.update',
SESSION_DELETE = 'session.delete', SESSION_DELETE = 'session.delete',

View file

@ -36,6 +36,10 @@ from
"sessions" "sessions"
where where
"sessions"."token" = $1 "sessions"."token" = $1
and (
"sessions"."expiredAt" is null
or "sessions"."expiredAt" > $2
)
-- SessionRepository.getByUserId -- SessionRepository.getByUserId
select select

View file

@ -54,7 +54,7 @@ export class CryptoRepository {
}); });
} }
newPassword(bytes: number) { randomBytesAsText(bytes: number) {
return randomBytes(bytes).toString('base64').replaceAll(/\W/g, ''); return randomBytes(bytes).toString('base64').replaceAll(/\W/g, '');
} }
} }

View file

@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Updateable } from 'kysely'; import { Insertable, Kysely, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { DateTime } from 'luxon';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database'; import { columns } from 'src/database';
import { DB, Sessions } from 'src/db'; import { DB, Sessions } from 'src/db';
@ -13,6 +14,19 @@ export type SessionSearchOptions = { updatedBefore: Date };
export class SessionRepository { export class SessionRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
cleanup() {
return this.db
.deleteFrom('sessions')
.where((eb) =>
eb.or([
eb('updatedAt', '<=', DateTime.now().minus({ days: 90 }).toJSDate()),
eb.and([eb('expiredAt', 'is not', null), eb('expiredAt', '<=', DateTime.now().toJSDate())]),
]),
)
.returning(['id', 'deviceOS', 'deviceType'])
.execute();
}
@GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] }) @GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] })
search(options: SessionSearchOptions) { search(options: SessionSearchOptions) {
return this.db return this.db
@ -37,6 +51,9 @@ export class SessionRepository {
).as('user'), ).as('user'),
]) ])
.where('sessions.token', '=', token) .where('sessions.token', '=', token)
.where((eb) =>
eb.or([eb('sessions.expiredAt', 'is', null), eb('sessions.expiredAt', '>', DateTime.now().toJSDate())]),
)
.executeTakeFirst(); .executeTakeFirst();
} }

View file

@ -0,0 +1,15 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "sessions" ADD "expiredAt" timestamp with time zone;`.execute(db);
await sql`ALTER TABLE "sessions" ADD "parentId" uuid;`.execute(db);
await sql`ALTER TABLE "sessions" ADD CONSTRAINT "FK_afbbabbd7daf5b91de4dca84de8" FOREIGN KEY ("parentId") REFERENCES "sessions" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE INDEX "IDX_afbbabbd7daf5b91de4dca84de" ON "sessions" ("parentId")`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "IDX_afbbabbd7daf5b91de4dca84de";`.execute(db);
await sql`ALTER TABLE "sessions" DROP CONSTRAINT "FK_afbbabbd7daf5b91de4dca84de8";`.execute(db);
await sql`ALTER TABLE "sessions" DROP COLUMN "expiredAt";`.execute(db);
await sql`ALTER TABLE "sessions" DROP COLUMN "parentId";`.execute(db);
}

View file

@ -25,9 +25,15 @@ export class SessionTable {
@UpdateDateColumn() @UpdateDateColumn()
updatedAt!: Date; updatedAt!: Date;
@Column({ type: 'timestamp with time zone', nullable: true })
expiredAt!: Date | null;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string; userId!: string;
@ForeignKeyColumn(() => SessionTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', nullable: true })
parentId!: string | null;
@Column({ default: '' }) @Column({ default: '' })
deviceType!: string; deviceType!: string;

View file

@ -18,7 +18,7 @@ describe(ApiKeyService.name, () => {
const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.ALL] }); const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.ALL] });
const key = 'super-secret'; const key = 'super-secret';
mocks.crypto.newPassword.mockReturnValue(key); mocks.crypto.randomBytesAsText.mockReturnValue(key);
mocks.apiKey.create.mockResolvedValue(apiKey); mocks.apiKey.create.mockResolvedValue(apiKey);
await sut.create(auth, { name: apiKey.name, permissions: apiKey.permissions }); await sut.create(auth, { name: apiKey.name, permissions: apiKey.permissions });
@ -29,7 +29,7 @@ describe(ApiKeyService.name, () => {
permissions: apiKey.permissions, permissions: apiKey.permissions,
userId: apiKey.userId, userId: apiKey.userId,
}); });
expect(mocks.crypto.newPassword).toHaveBeenCalled(); expect(mocks.crypto.randomBytesAsText).toHaveBeenCalled();
expect(mocks.crypto.hashSha256).toHaveBeenCalled(); expect(mocks.crypto.hashSha256).toHaveBeenCalled();
}); });
@ -38,7 +38,7 @@ describe(ApiKeyService.name, () => {
const apiKey = factory.apiKey({ userId: auth.user.id }); const apiKey = factory.apiKey({ userId: auth.user.id });
const key = 'super-secret'; const key = 'super-secret';
mocks.crypto.newPassword.mockReturnValue(key); mocks.crypto.randomBytesAsText.mockReturnValue(key);
mocks.apiKey.create.mockResolvedValue(apiKey); mocks.apiKey.create.mockResolvedValue(apiKey);
await sut.create(auth, { permissions: [Permission.ALL] }); await sut.create(auth, { permissions: [Permission.ALL] });
@ -49,7 +49,7 @@ describe(ApiKeyService.name, () => {
permissions: [Permission.ALL], permissions: [Permission.ALL],
userId: auth.user.id, userId: auth.user.id,
}); });
expect(mocks.crypto.newPassword).toHaveBeenCalled(); expect(mocks.crypto.randomBytesAsText).toHaveBeenCalled();
expect(mocks.crypto.hashSha256).toHaveBeenCalled(); expect(mocks.crypto.hashSha256).toHaveBeenCalled();
}); });

View file

@ -9,20 +9,21 @@ import { isGranted } from 'src/utils/access';
@Injectable() @Injectable()
export class ApiKeyService extends BaseService { export class ApiKeyService extends BaseService {
async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> { async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
const secret = this.cryptoRepository.newPassword(32); const token = this.cryptoRepository.randomBytesAsText(32);
const tokenHashed = this.cryptoRepository.hashSha256(token);
if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) { if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) {
throw new BadRequestException('Cannot grant permissions you do not have'); throw new BadRequestException('Cannot grant permissions you do not have');
} }
const entity = await this.apiKeyRepository.create({ const entity = await this.apiKeyRepository.create({
key: this.cryptoRepository.hashSha256(secret), key: tokenHashed,
name: dto.name || 'API Key', name: dto.name || 'API Key',
userId: auth.user.id, userId: auth.user.id,
permissions: dto.permissions, permissions: dto.permissions,
}); });
return { secret, apiKey: this.map(entity) }; return { secret: token, apiKey: this.map(entity) };
} }
async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise<APIKeyResponseDto> { async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise<APIKeyResponseDto> {

View file

@ -492,17 +492,17 @@ export class AuthService extends BaseService {
} }
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) { private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) {
const key = this.cryptoRepository.newPassword(32); const token = this.cryptoRepository.randomBytesAsText(32);
const token = this.cryptoRepository.hashSha256(key); const tokenHashed = this.cryptoRepository.hashSha256(token);
await this.sessionRepository.create({ await this.sessionRepository.create({
token, token: tokenHashed,
deviceOS: loginDetails.deviceOS, deviceOS: loginDetails.deviceOS,
deviceType: loginDetails.deviceType, deviceType: loginDetails.deviceType,
userId: user.id, userId: user.id,
}); });
return mapLoginResponse(user, key); return mapLoginResponse(user, token);
} }
private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T { private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T {

View file

@ -17,7 +17,7 @@ export class CliService extends BaseService {
} }
const providedPassword = await ask(mapUserAdmin(admin)); const providedPassword = await ask(mapUserAdmin(admin));
const password = providedPassword || this.cryptoRepository.newPassword(24); const password = providedPassword || this.cryptoRepository.randomBytesAsText(24);
const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS); const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS);
await this.userRepository.update(admin.id, { password: hashedPassword }); await this.userRepository.update(admin.id, { password: hashedPassword });

View file

@ -17,30 +17,9 @@ describe('SessionService', () => {
}); });
describe('handleCleanup', () => { describe('handleCleanup', () => {
it('should return skipped if nothing is to be deleted', async () => { it('should clean sessions', async () => {
mocks.session.search.mockResolvedValue([]); mocks.session.cleanup.mockResolvedValue([]);
await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SKIPPED);
expect(mocks.session.search).toHaveBeenCalled();
});
it('should delete sessions', async () => {
mocks.session.search.mockResolvedValue([
{
createdAt: new Date('1970-01-01T00:00:00.00Z'),
updatedAt: new Date('1970-01-02T00:00:00.00Z'),
deviceOS: '',
deviceType: '',
id: '123',
token: '420',
userId: '42',
updateId: 'uuid-v7',
pinExpiresAt: null,
},
]);
mocks.session.delete.mockResolvedValue();
await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SUCCESS); await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SUCCESS);
expect(mocks.session.delete).toHaveBeenCalledWith('123');
}); });
}); });

View file

@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { OnJob } from 'src/decorators'; import { OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto, mapSession } from 'src/dtos/session.dto';
import { JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
@ -10,16 +10,8 @@ import { BaseService } from 'src/services/base.service';
export class SessionService extends BaseService { export class SessionService extends BaseService {
@OnJob({ name: JobName.CLEAN_OLD_SESSION_TOKENS, queue: QueueName.BACKGROUND_TASK }) @OnJob({ name: JobName.CLEAN_OLD_SESSION_TOKENS, queue: QueueName.BACKGROUND_TASK })
async handleCleanup(): Promise<JobStatus> { async handleCleanup(): Promise<JobStatus> {
const sessions = await this.sessionRepository.search({ const sessions = await this.sessionRepository.cleanup();
updatedBefore: DateTime.now().minus({ days: 90 }).toJSDate(),
});
if (sessions.length === 0) {
return JobStatus.SKIPPED;
}
for (const session of sessions) { for (const session of sessions) {
await this.sessionRepository.delete(session.id);
this.logger.verbose(`Deleted expired session token: ${session.deviceOS}/${session.deviceType}`); this.logger.verbose(`Deleted expired session token: ${session.deviceOS}/${session.deviceType}`);
} }
@ -28,6 +20,25 @@ export class SessionService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async create(auth: AuthDto, dto: SessionCreateDto): Promise<SessionCreateResponseDto> {
if (!auth.session) {
throw new BadRequestException('This endpoint can only be used with a session token');
}
const token = this.cryptoRepository.randomBytesAsText(32);
const tokenHashed = this.cryptoRepository.hashSha256(token);
const session = await this.sessionRepository.create({
parentId: auth.session.id,
userId: auth.user.id,
expiredAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null,
deviceType: dto.deviceType,
deviceOS: dto.deviceOS,
token: tokenHashed,
});
return { ...mapSession(session), token };
}
async getAll(auth: AuthDto): Promise<SessionResponseDto[]> { async getAll(auth: AuthDto): Promise<SessionResponseDto[]> {
const sessions = await this.sessionRepository.getByUserId(auth.user.id); const sessions = await this.sessionRepository.getByUserId(auth.user.id);
return sessions.map((session) => mapSession(session, auth.session?.id)); return sessions.map((session) => mapSession(session, auth.session?.id));

View file

@ -12,6 +12,6 @@ export const newCryptoRepositoryMock = (): Mocked<RepositoryInterface<CryptoRepo
verifySha256: vitest.fn().mockImplementation(() => true), verifySha256: vitest.fn().mockImplementation(() => true),
hashSha1: vitest.fn().mockImplementation((input) => Buffer.from(`${input.toString()} (hashed)`)), hashSha1: vitest.fn().mockImplementation((input) => Buffer.from(`${input.toString()} (hashed)`)),
hashFile: vitest.fn().mockImplementation((input) => `${input} (file-hashed)`), hashFile: vitest.fn().mockImplementation((input) => `${input} (file-hashed)`),
newPassword: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')), randomBytesAsText: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')),
}; };
}; };

View file

@ -126,6 +126,8 @@ const sessionFactory = (session: Partial<Session> = {}) => ({
deviceOS: 'android', deviceOS: 'android',
deviceType: 'mobile', deviceType: 'mobile',
token: 'abc123', token: 'abc123',
parentId: null,
expiredAt: null,
userId: newUuid(), userId: newUuid(),
pinExpiresAt: newDate(), pinExpiresAt: newDate(),
...session, ...session,

View file

@ -7,6 +7,7 @@
mdiAndroid, mdiAndroid,
mdiApple, mdiApple,
mdiAppleSafari, mdiAppleSafari,
mdiCast,
mdiGoogleChrome, mdiGoogleChrome,
mdiHelp, mdiHelp,
mdiLinux, mdiLinux,
@ -46,6 +47,8 @@
<Icon path={mdiUbuntu} size="40" /> <Icon path={mdiUbuntu} size="40" />
{:else if device.deviceOS === 'Chrome OS' || device.deviceType === 'Chrome' || device.deviceType === 'Chromium' || device.deviceType === 'Mobile Chrome'} {:else if device.deviceOS === 'Chrome OS' || device.deviceType === 'Chrome' || device.deviceType === 'Chromium' || device.deviceType === 'Mobile Chrome'}
<Icon path={mdiGoogleChrome} size="40" /> <Icon path={mdiGoogleChrome} size="40" />
{:else if device.deviceOS === 'Google Cast'}
<Icon path={mdiCast} size="40" />
{:else} {:else}
<Icon path={mdiHelp} size="40" /> <Icon path={mdiHelp} size="40" />
{/if} {/if}