Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Misc website fixes #915

Merged
merged 2 commits into from
Jun 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

namespace DragaliaAPI.Features.Web.Savefile;

[Route("/api/savefile")]
public class SavefileController(ILoadService loadService) : ControllerBase
{
[HttpGet("export")]
Expand Down
102 changes: 86 additions & 16 deletions DragaliaAPI/DragaliaAPI/Features/Web/WebAuthenticationHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
Expand Down Expand Up @@ -48,32 +49,18 @@ public static async Task OnTokenValidated(TokenValidatedContext context)
);
}

string accountId = jsonWebToken.Subject;

ApiContext dbContext = context.HttpContext.RequestServices.GetRequiredService<ApiContext>();
ILogger logger = context
.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger("DragaliaAPI.Features.Web.WebAuthenticationHelper");

// TODO: Cache this query in Redis
var playerInfo = await dbContext
.Players.IgnoreQueryFilters()
.Where(x => x.AccountId == accountId)
.Select(x => new { x.ViewerId, x.UserData!.Name, })
.FirstOrDefaultAsync();

logger.LogDebug(
"Player info for account {AccountId}: {@PlayerInfo}",
accountId,
playerInfo
);
PlayerInfo? playerInfo = await GetPlayerInfo(context, jsonWebToken, logger);

if (playerInfo is not null)
{
ClaimsIdentity playerIdentity =
new(
[
new Claim(CustomClaimType.AccountId, accountId),
new Claim(CustomClaimType.AccountId, playerInfo.AccountId),
new Claim(CustomClaimType.ViewerId, playerInfo.ViewerId.ToString()),
new Claim(CustomClaimType.PlayerName, playerInfo.Name),
]
Expand All @@ -85,4 +72,87 @@ public static async Task OnTokenValidated(TokenValidatedContext context)
context.Principal?.AddIdentity(playerIdentity);
}
}

private static async Task<PlayerInfo?> GetPlayerInfo(
TokenValidatedContext context,
JsonWebToken jwt,
ILogger logger
)
{
// TODO: Rewrite using HybridCache when .NET 9 releases
IDistributedCache cache =
context.HttpContext.RequestServices.GetRequiredService<IDistributedCache>();

string cacheKey = $":playerinfo:${jwt.Subject}";

if (await cache.GetJsonAsync<PlayerInfo>(cacheKey) is { } cachedPlayerInfo)
{
logger.LogDebug("Using cached player info: {@PlayerInfo}", cachedPlayerInfo);
return cachedPlayerInfo;
}

IBaasApi baasApi = context.HttpContext.RequestServices.GetRequiredService<IBaasApi>();
string? gameAccountId = await baasApi.GetUserId(jwt.EncodedToken);

logger.LogDebug(
"Retrieved game account {GameAccountId} from BaaS for web account {WebAccountId}",
gameAccountId,
jwt.Subject
);

if (gameAccountId is null)
{
return null;
}

ApiContext dbContext = context.HttpContext.RequestServices.GetRequiredService<ApiContext>();

var dbPlayerInfo = await dbContext
.Players.IgnoreQueryFilters()
.Where(x => x.AccountId == gameAccountId)
.Select(x => new { x.ViewerId, x.UserData!.Name, })
.FirstOrDefaultAsync();

logger.LogDebug(
"PlayerInfo for game account {GameAccountId}: {@PlayerInfo}",
gameAccountId,
dbPlayerInfo
);

if (dbPlayerInfo is null)
{
return null;
}

PlayerInfo playerInfo =
new()
{
AccountId = gameAccountId,
Name = dbPlayerInfo.Name,
ViewerId = dbPlayerInfo.ViewerId,
};

await cache.SetJsonAsync(
cacheKey,
playerInfo,
new DistributedCacheEntryOptions()
{
// The ID token lasts for one hour. We may retain cached data past the expiry of the ID token, but
// that should be okay, since the JWT authentication will return an unauthorized result before reaching
// this code.
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
}
);

return playerInfo;
}

private class PlayerInfo
{
public required string AccountId { get; init; }

public long ViewerId { get; init; }

public required string Name { get; init; }
}
}
32 changes: 31 additions & 1 deletion DragaliaAPI/DragaliaAPI/Services/Api/BaasApi.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using DragaliaAPI.Models.Generated;
using System.Net;
using System.Net.Http.Headers;
using DragaliaAPI.Models.Generated;
using DragaliaAPI.Models.Options;
using DragaliaAPI.Services.Exceptions;
using DragaliaAPI.Shared.Serialization;
Expand Down Expand Up @@ -85,4 +87,32 @@ await savefileResponse.Content.ReadFromJsonAsync<
>(ApiJsonOptions.Instance)
)?.Data ?? throw new JsonException("Deserialized savefile was null");
}

public async Task<string?> GetUserId(string idToken)
{
HttpRequestMessage request =
new(HttpMethod.Get, "/gameplay/v1/user")
{
Headers = { Authorization = new AuthenticationHeaderValue("Bearer", idToken) }
};

HttpResponseMessage response = await client.SendAsync(request);

if (!response.IsSuccessStatusCode)
{
this.logger.LogError(
"Received non-200 status code in GetUserId: {Status}",
response.StatusCode
);

return null;
}

return (await response.Content.ReadFromJsonAsync<UserIdResponse>())?.UserId;
}

private class UserIdResponse
{
public required string UserId { get; set; }
}
}
1 change: 1 addition & 0 deletions DragaliaAPI/DragaliaAPI/Services/Api/IBaasApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ public interface IBaasApi
{
Task<IList<SecurityKey>> GetKeys();
Task<LoadIndexResponse> GetSavefile(string idToken);
Task<string?> GetUserId(string idToken);
}
1 change: 0 additions & 1 deletion Website/.env
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
PUBLIC_BAAS_URL=https://baas.lukefz.xyz/ # The URL of the BaaS server to use for logins.
PUBLIC_BAAS_CLIENT_ID=dawnshard # The client ID to present to the BaaS during OAuth.
PUBLIC_DAWNSHARD_API_URL=https://dawnshard.co.uk/api/ # The public URL of the main DragaliaAPI C# server.
DAWNSHARD_API_URL_SSR=http://localhost:5000/ # The internal URL of the main DragaliaAPI C# server.
1 change: 0 additions & 1 deletion Website/.env.development
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
PUBLIC_ENABLE_MSW=true # Enables Mock Service Worker for API calls and decreases cookie security.
PUBLIC_DAWNSHARD_API_URL=http://localhost:3001/api/
DAWNSHARD_API_URL_SSR=http://localhost:5000/
8 changes: 3 additions & 5 deletions Website/src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import type { Handle, HandleFetch } from '@sveltejs/kit';

import { env } from '$env/dynamic/private';
import { PUBLIC_DAWNSHARD_API_URL } from '$env/static/public';
import { PUBLIC_ENABLE_MSW } from '$env/static/public';
import Cookies from '$lib/auth/cookies.ts';
import getJwtMetadata from '$lib/auth/jwt.ts';

const publicApiUrl = new URL(PUBLIC_DAWNSHARD_API_URL);
const internalApiUrl = new URL(env.DAWNSHARD_API_URL_SSR);

if (PUBLIC_ENABLE_MSW === 'true') {
Expand All @@ -20,12 +18,12 @@ if (PUBLIC_ENABLE_MSW === 'true') {
});
}

export const handleFetch: HandleFetch = ({ request, fetch }) => {
export const handleFetch: HandleFetch = ({ request, fetch, event }) => {
const requestUrl = new URL(request.url);

if (requestUrl.origin === publicApiUrl.origin) {
if (event.url.origin === requestUrl.origin && requestUrl.pathname.startsWith('/api')) {
// Rewrite URL to internal
const newUrl = request.url.replace(publicApiUrl.origin, internalApiUrl.origin);
const newUrl = request.url.replace(requestUrl.origin, internalApiUrl.origin);
console.log(`Rewriting request: from ${requestUrl.href} to ${newUrl}`);
return fetch(new Request(newUrl, request));
}
Expand Down
5 changes: 2 additions & 3 deletions Website/src/routes/(main)/account/+layout.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { redirect } from '@sveltejs/kit';

import { PUBLIC_DAWNSHARD_API_URL } from '$env/static/public';
import { userSchema } from '$main/account/user.ts';

import type { LayoutLoad } from './$types';

export const load: LayoutLoad = async ({ fetch }) => {
const userRequest = new URL('user/me', PUBLIC_DAWNSHARD_API_URL);
export const load: LayoutLoad = async ({ fetch, url }) => {
const userRequest = new URL('/api/user/me', url.origin);

const response = await fetch(userRequest);

Expand Down
6 changes: 2 additions & 4 deletions Website/src/routes/(main)/account/profile/+page.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { PUBLIC_DAWNSHARD_API_URL } from '$env/static/public';

import type { PageLoad } from './$types';
import { userProfileSchema } from './userProfile.ts';

export const load: PageLoad = async ({ fetch }) => {
const userRequest = new URL('user/me/profile', PUBLIC_DAWNSHARD_API_URL);
export const load: PageLoad = async ({ fetch, url }) => {
const userRequest = new URL('/api/user/me/profile', url.origin);

const response = await fetch(userRequest);

Expand Down
4 changes: 2 additions & 2 deletions Website/src/routes/(main)/account/profile/saveExport.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
import Upload from 'lucide-svelte/icons/upload';
import { onMount } from 'svelte';

import { PUBLIC_DAWNSHARD_API_URL } from '$env/static/public';
import { page } from '$app/stores';
import LoadingSpinner from '$lib/components/loadingSpinner.svelte';
import { Button } from '$shadcn/components/ui/button';
import * as Card from '$shadcn/components/ui/card';

let enhance = false;
let savefileExportPromise: Promise<void> | null = null;

const savefileExportUrl = new URL('savefile/export', PUBLIC_DAWNSHARD_API_URL);
const savefileExportUrl = new URL('/api/savefile/export', $page.url.origin);

const getSavefile = async () => {
const response = await fetch(savefileExportUrl);
Expand Down
4 changes: 2 additions & 2 deletions Website/src/routes/(main)/news/[pageNo=integer]/+page.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { makeRequestUrl, newsSchema } from '../news.ts';
import type { PageLoad } from './$types';

export const load: PageLoad = async ({ fetch, params }) => {
export const load: PageLoad = async ({ fetch, params, url }) => {
const pageNo = Number.parseInt(params.pageNo) || 1;
const requestUrl = makeRequestUrl(pageNo);
const requestUrl = makeRequestUrl(pageNo, url.origin);

const response = await fetch(requestUrl);
if (!response.ok) {
Expand Down
6 changes: 2 additions & 4 deletions Website/src/routes/(main)/news/news.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { z } from 'zod';

import { PUBLIC_DAWNSHARD_API_URL } from '$env/static/public';

export type NewsItem = z.infer<typeof newsSchema>['data'][0];

export const pageSize = 5;
Expand All @@ -26,11 +24,11 @@ export const newsSchema = z.object({
data: newsItemSchema.array()
});

export const makeRequestUrl = (pageNo: number) => {
export const makeRequestUrl = (pageNo: number, urlOrigin: string) => {
const offset = (pageNo - 1) * pageSize;
const query = new URLSearchParams({
offset: offset.toString(),
pageSize: pageSize.toString()
});
return new URL(`news?${query}`, PUBLIC_DAWNSHARD_API_URL);
return new URL(`/api/news?${query}`, urlOrigin);
};
12 changes: 4 additions & 8 deletions Website/src/routes/(main)/oauth/+page.server.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import { type Cookies, redirect } from '@sveltejs/kit';
import { z } from 'zod';

import {
PUBLIC_BAAS_CLIENT_ID,
PUBLIC_BAAS_URL,
PUBLIC_DAWNSHARD_API_URL,
PUBLIC_ENABLE_MSW
} from '$env/static/public';
import { PUBLIC_BAAS_CLIENT_ID, PUBLIC_BAAS_URL, PUBLIC_ENABLE_MSW } from '$env/static/public';
import CookieNames from '$lib/auth/cookies.ts';
import getJwtMetadata from '$lib/auth/jwt.ts';

Expand All @@ -33,7 +28,7 @@ export const load: PageServerLoad = async ({ cookies, url, fetch }) => {

const maxAge = (jwtMetadata.expiryTimestampMs - Date.now()) / 1000;

if (!(await checkUserExists(idToken, fetch))) {
if (!(await checkUserExists(idToken, url, fetch))) {
redirect(302, '/login/unauthorized/404');
}

Expand Down Expand Up @@ -121,9 +116,10 @@ const getBaasToken = async (

const checkUserExists = async (
idToken: string,
url: URL,
fetch: (url: URL, req: RequestInit) => Promise<Response>
) => {
const userMeResponse = await fetch(new URL('user/me', PUBLIC_DAWNSHARD_API_URL), {
const userMeResponse = await fetch(new URL('/api/user/me', url.origin), {
headers: {
Authorization: `Bearer ${idToken}`
}
Expand Down
4 changes: 2 additions & 2 deletions Website/src/routes/webview/news/[pageNo=integer]/+page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { makeRequestUrl, newsSchema } from '$main/news/news.ts';

import type { PageLoad } from './$types';

export const load: PageLoad = async ({ fetch, params }) => {
export const load: PageLoad = async ({ fetch, params, url }) => {
const pageNo = Number.parseInt(params.pageNo) || 1;
const requestUrl = makeRequestUrl(pageNo);
const requestUrl = makeRequestUrl(pageNo, url.origin);

const response = await fetch(requestUrl);

Expand Down
5 changes: 2 additions & 3 deletions Website/src/routes/webview/news/detail/[id=integer]/+page.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { PUBLIC_DAWNSHARD_API_URL } from '$env/static/public';
import { newsItemSchema } from '$main/news/news.ts';

import type { PageLoad } from './$types';

export const load: PageLoad = async ({ fetch, params }) => {
export const load: PageLoad = async ({ fetch, params, url }) => {
const id = Number.parseInt(params.id) || 1;
const requestUrl = new URL(`news/${id}`, PUBLIC_DAWNSHARD_API_URL);
const requestUrl = new URL(`/api/news/${id}`, url.origin);

const response = await fetch(requestUrl);

Expand Down
8 changes: 7 additions & 1 deletion Website/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export default defineConfig(({ mode }) => ({
target: mode === 'development' ? 'es2022' : 'modules'
},
preview: {
port: 3001
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true
}
}
}
}));
Loading