Convert to Blazor (#6)
Convert existing progress to Blazor on client and server
This commit was merged in pull request #6.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
using JobsJobsJobs.Server.Data;
|
||||
using JobsJobsJobs.Shared;
|
||||
using JobsJobsJobs.Shared.Api;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NodaTime;
|
||||
using Npgsql;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||
{
|
||||
[Route("api/[controller]")]
|
||||
[ApiController]
|
||||
public class CitizenController : ControllerBase
|
||||
{
|
||||
private readonly IConfigurationSection _config;
|
||||
|
||||
private readonly IClock _clock;
|
||||
|
||||
private readonly NpgsqlConnection _db;
|
||||
|
||||
public CitizenController(IConfiguration config, IClock clock, NpgsqlConnection db)
|
||||
{
|
||||
_config = config.GetSection("Auth");
|
||||
_clock = clock;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
[HttpGet("log-on/{authCode}")]
|
||||
public async Task<IActionResult> LogOn([FromRoute] string authCode)
|
||||
{
|
||||
// Step 1 - Verify with Mastodon
|
||||
var accountResult = await Auth.VerifyWithMastodon(authCode, _config);
|
||||
|
||||
if (accountResult.IsError) return BadRequest(accountResult.Error);
|
||||
|
||||
// Step 2 - Find / establish Jobs, Jobs, Jobs account
|
||||
var account = accountResult.Ok;
|
||||
var now = _clock.GetCurrentInstant();
|
||||
|
||||
await _db.OpenAsync();
|
||||
var citizen = await _db.FindCitizenByNAUser(account.Username);
|
||||
if (citizen == null)
|
||||
{
|
||||
citizen = new Citizen(await CitizenId.Create(), account.Username, account.DisplayName, account.Url,
|
||||
now, now);
|
||||
await _db.AddCitizen(citizen);
|
||||
}
|
||||
else
|
||||
{
|
||||
citizen = citizen with
|
||||
{
|
||||
DisplayName = account.DisplayName,
|
||||
LastSeenOn = now
|
||||
};
|
||||
await _db.UpdateCitizenOnLogOn(citizen);
|
||||
}
|
||||
|
||||
// Step 3 - Generate JWT
|
||||
var jwt = Auth.CreateJwt(citizen, _config);
|
||||
|
||||
return new JsonResult(new LogOnSuccess(jwt, citizen.Id.ToString(), citizen.DisplayName));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using JobsJobsJobs.Server.Data;
|
||||
using JobsJobsJobs.Shared;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||
{
|
||||
[Route("api/[controller]")]
|
||||
[ApiController]
|
||||
public class ProfileController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The database connection
|
||||
/// </summary>
|
||||
private readonly NpgsqlConnection db;
|
||||
|
||||
public ProfileController(NpgsqlConnection dbConn)
|
||||
{
|
||||
db = dbConn;
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Get()
|
||||
{
|
||||
await db.OpenAsync();
|
||||
var profile = await db.FindProfileByCitizen(
|
||||
CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value));
|
||||
return profile == null ? NoContent() : Ok(profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/JobsJobsJobs/Server/Auth.cs
Normal file
112
src/JobsJobsJobs/Server/Auth.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using JobsJobsJobs.Server.Models;
|
||||
using JobsJobsJobs.Shared;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Authentication / authorization utility methods
|
||||
/// </summary>
|
||||
public static class Auth
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify the authorization code with Mastodon and get the user's profile
|
||||
/// </summary>
|
||||
/// <param name="authCode">The code from the authorization flow</param>
|
||||
/// <param name="config">The authorization configuration section</param>
|
||||
/// <returns>The Mastodon account (or an error if one is encountered)</returns>
|
||||
public static async Task<Result<MastodonAccount>> VerifyWithMastodon(string authCode,
|
||||
IConfigurationSection config)
|
||||
{
|
||||
using var http = new HttpClient();
|
||||
|
||||
// Use authorization code to get an access token from NAS
|
||||
using var codeResult = await http.PostAsJsonAsync("https://noagendasocial.com/oauth/token", new
|
||||
{
|
||||
client_id = config["ClientId"],
|
||||
client_secret = config["Secret"],
|
||||
redirect_uri = "https://localhost:3005/citizen/authorized",
|
||||
grant_type = "authorization_code",
|
||||
code = authCode,
|
||||
scope = "read"
|
||||
});
|
||||
if (!codeResult.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine($"ERR: {await codeResult.Content.ReadAsStringAsync()}");
|
||||
return Result<MastodonAccount>.AsError(
|
||||
$"Could not get token ({codeResult.StatusCode:D}: {codeResult.ReasonPhrase})");
|
||||
}
|
||||
|
||||
using var tokenResponse = JsonSerializer.Deserialize<JsonDocument>(
|
||||
new ReadOnlySpan<byte>(await codeResult.Content.ReadAsByteArrayAsync()));
|
||||
if (tokenResponse == null)
|
||||
{
|
||||
return Result<MastodonAccount>.AsError("Could not parse authorization code result");
|
||||
}
|
||||
|
||||
var accessToken = tokenResponse.RootElement.GetProperty("access_token").GetString();
|
||||
|
||||
// Use access token to get profile from NAS
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, $"{config["ApiUrl"]}accounts/verify_credentials");
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
using var profileResult = await http.SendAsync(req);
|
||||
if (!profileResult.IsSuccessStatusCode)
|
||||
{
|
||||
return Result<MastodonAccount>.AsError(
|
||||
$"Could not get profile ({profileResult.StatusCode:D}: {profileResult.ReasonPhrase})");
|
||||
}
|
||||
|
||||
var profileResponse = JsonSerializer.Deserialize<MastodonAccount>(
|
||||
new ReadOnlySpan<byte>(await profileResult.Content.ReadAsByteArrayAsync()));
|
||||
if (profileResponse == null)
|
||||
{
|
||||
return Result<MastodonAccount>.AsError("Could not parse profile result");
|
||||
}
|
||||
|
||||
if (profileResponse.Username != profileResponse.AccountName)
|
||||
{
|
||||
return Result<MastodonAccount>.AsError(
|
||||
$"Profiles must be from noagendasocial.com; yours is {profileResponse.AccountName}");
|
||||
}
|
||||
|
||||
return Result<MastodonAccount>.AsOk(profileResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a JSON Web Token for this citizen to use for further requests to this API
|
||||
/// </summary>
|
||||
/// <param name="citizen">The citizen for which the token should be generated</param>
|
||||
/// <param name="config">The authorization configuration section</param>
|
||||
/// <returns>The JWT</returns>
|
||||
public static string CreateJwt(Citizen citizen, IConfigurationSection config)
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var token = tokenHandler.CreateToken(new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, citizen.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, citizen.DisplayName),
|
||||
}),
|
||||
Expires = DateTime.UtcNow.AddHours(2),
|
||||
Issuer = "https://jobsjobs.jobs",
|
||||
Audience = "https://jobsjobs.jobs",
|
||||
SigningCredentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["ServerSecret"])),
|
||||
SecurityAlgorithms.HmacSha256Signature)
|
||||
});
|
||||
return tokenHandler.WriteToken(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/JobsJobsJobs/Server/Data/CitizenExtensions.cs
Normal file
86
src/JobsJobsJobs/Server/Data/CitizenExtensions.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using JobsJobsJobs.Shared;
|
||||
using Npgsql;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions to the NpgslConnection type supporting the manipulation of citizens
|
||||
/// </summary>
|
||||
public static class CitizenExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Populate a citizen object from the given data reader
|
||||
/// </summary>
|
||||
/// <param name="rdr">The data reader from which the values should be obtained</param>
|
||||
/// <returns>A populated citizen</returns>
|
||||
private static Citizen ToCitizen(NpgsqlDataReader rdr) =>
|
||||
new Citizen(CitizenId.Parse(rdr.GetString("id")), rdr.GetString("na_user"), rdr.GetString("display_name"),
|
||||
rdr.GetString("profile_url"), rdr.GetInstant("joined_on"), rdr.GetInstant("last_seen_on"));
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve a citizen by their No Agenda Social user name
|
||||
/// </summary>
|
||||
/// <param name="naUser">The NAS user name</param>
|
||||
/// <returns>The citizen, or null if not found</returns>
|
||||
public static async Task<Citizen?> FindCitizenByNAUser(this NpgsqlConnection conn, string naUser)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT * FROM citizen WHERE na_user = @na_user";
|
||||
cmd.Parameters.Add(new NpgsqlParameter("@na_user", naUser));
|
||||
|
||||
using NpgsqlDataReader rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
|
||||
if (await rdr.ReadAsync().ConfigureAwait(false))
|
||||
{
|
||||
return ToCitizen(rdr);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a citizen
|
||||
/// </summary>
|
||||
/// <param name="citizen">The citizen to be added</param>
|
||||
public static async Task AddCitizen(this NpgsqlConnection conn, Citizen citizen)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
@"INSERT INTO citizen (
|
||||
na_user, display_name, profile_url, joined_on, last_seen_on, id
|
||||
) VALUES(
|
||||
@na_user, @display_name, @profile_url, @joined_on, @last_seen_on, @id
|
||||
)";
|
||||
cmd.Parameters.Add(new NpgsqlParameter("@id", citizen.Id.ToString()));
|
||||
cmd.Parameters.Add(new NpgsqlParameter("@na_user", citizen.NaUser));
|
||||
cmd.Parameters.Add(new NpgsqlParameter("@display_name", citizen.DisplayName));
|
||||
cmd.Parameters.Add(new NpgsqlParameter("@profile_url", citizen.ProfileUrl));
|
||||
cmd.Parameters.Add(new NpgsqlParameter("@joined_on", citizen.JoinedOn));
|
||||
cmd.Parameters.Add(new NpgsqlParameter("@last_seen_on", citizen.LastSeenOn));
|
||||
|
||||
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update a citizen after they have logged on (update last seen, sync display name)
|
||||
/// </summary>
|
||||
/// <param name="citizen">The updated citizen</param>
|
||||
public static async Task UpdateCitizenOnLogOn(this NpgsqlConnection conn, Citizen citizen)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
@"UPDATE citizen
|
||||
SET display_name = @display_name,
|
||||
last_seen_on = @last_seen_on
|
||||
WHERE id = @id";
|
||||
cmd.Parameters.Add(new NpgsqlParameter("@id", citizen.Id.ToString()));
|
||||
cmd.Parameters.Add(new NpgsqlParameter("@display_name", citizen.DisplayName));
|
||||
cmd.Parameters.Add(new NpgsqlParameter("@last_seen_on", citizen.LastSeenOn));
|
||||
|
||||
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/JobsJobsJobs/Server/Data/NpgsqlDataReaderExtensions.cs
Normal file
52
src/JobsJobsJobs/Server/Data/NpgsqlDataReaderExtensions.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using JobsJobsJobs.Shared;
|
||||
using NodaTime;
|
||||
using Npgsql;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions to the Npgsql data reader
|
||||
/// </summary>
|
||||
public static class NpgsqlDataReaderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Get a boolean by its name
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the field to be retrieved as a boolean</param>
|
||||
/// <returns>The specified field as a boolean</returns>
|
||||
public static bool GetBoolean(this NpgsqlDataReader rdr, string name) => rdr.GetBoolean(rdr.GetOrdinal(name));
|
||||
|
||||
/// <summary>
|
||||
/// Get an Instant by its name
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the field to be retrieved as an Instant</param>
|
||||
/// <returns>The specified field as an Instant</returns>
|
||||
public static Instant GetInstant(this NpgsqlDataReader rdr, string name) =>
|
||||
rdr.GetFieldValue<Instant>(rdr.GetOrdinal(name));
|
||||
|
||||
/// <summary>
|
||||
/// Get a 64-bit integer by its name
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the field to be retrieved as a 64-bit integer</param>
|
||||
/// <returns>The specified field as a 64-bit integer</returns>
|
||||
public static long GetInt64(this NpgsqlDataReader rdr, string name) => rdr.GetInt64(rdr.GetOrdinal(name));
|
||||
|
||||
/// <summary>
|
||||
/// Get a string by its name
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the field to be retrieved as a string</param>
|
||||
/// <returns>The specified field as a string</returns>
|
||||
public static string GetString(this NpgsqlDataReader rdr, string name) => rdr.GetString(rdr.GetOrdinal(name));
|
||||
|
||||
/// <summary>
|
||||
/// Determine if a column is null
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the column to check</param>
|
||||
/// <returns>True if the column is null, false if not</returns>
|
||||
public static bool IsDBNull(this NpgsqlDataReader rdr, string name) => rdr.IsDBNull(rdr.GetOrdinal(name));
|
||||
}
|
||||
}
|
||||
52
src/JobsJobsJobs/Server/Data/ProfileExtensions.cs
Normal file
52
src/JobsJobsJobs/Server/Data/ProfileExtensions.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using JobsJobsJobs.Shared;
|
||||
using Npgsql;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions to the NpgsqlConnection type to support manipulation of profiles
|
||||
/// </summary>
|
||||
public static class ProfileExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Populate a profile object from the given data reader
|
||||
/// </summary>
|
||||
/// <param name="rdr">The data reader from which values should be obtained</param>
|
||||
/// <returns>The populated profile</returns>
|
||||
private static Profile ToProfile(NpgsqlDataReader rdr)
|
||||
{
|
||||
var continentId = ContinentId.Parse(rdr.GetString("continent_id"));
|
||||
return new Profile(CitizenId.Parse(rdr.GetString("id")), rdr.GetBoolean("seeking_employment"),
|
||||
rdr.GetBoolean("is_public"), continentId, rdr.GetString("region"), rdr.GetBoolean("remote_work"),
|
||||
rdr.GetBoolean("full_time"), new MarkdownString(rdr.GetString("biography")),
|
||||
rdr.GetInstant("last_updated_on"),
|
||||
rdr.IsDBNull("experience") ? null : new MarkdownString(rdr.GetString("experience")))
|
||||
{
|
||||
Continent = new Continent(continentId, rdr.GetString("continent_name"))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve an employment profile by a citizen ID
|
||||
/// </summary>
|
||||
/// <param name="citizen">The ID of the citizen whose profile should be retrieved</param>
|
||||
/// <returns>The profile, or null if it does not exist</returns>
|
||||
public static async Task<Profile?> FindProfileByCitizen(this NpgsqlConnection conn, CitizenId citizen)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
@"SELECT p.*, c.name AS continent_name
|
||||
FROM profile p
|
||||
INNER JOIN continent c ON p.continent_id = c.id
|
||||
WHERE citizen_id = @id";
|
||||
cmd.Parameters.Add(new NpgsqlParameter("@id", citizen.Id.ToString()));
|
||||
|
||||
using var rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
|
||||
return await rdr.ReadAsync().ConfigureAwait(false) ? ToProfile(rdr) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/JobsJobsJobs/Server/JobsJobsJobs.Server.csproj
Normal file
27
src/JobsJobsJobs/Server/JobsJobsJobs.Server.csproj
Normal file
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="5.0.1" />
|
||||
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.0.0" />
|
||||
<PackageReference Include="Npgsql" Version="5.0.1.1" />
|
||||
<PackageReference Include="Npgsql.NodaTime" Version="5.0.1.1" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.8.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Client\JobsJobsJobs.Client.csproj" />
|
||||
<ProjectReference Include="..\Shared\JobsJobsJobs.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Controllers\" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
</Project>
|
||||
13
src/JobsJobsJobs/Server/JobsJobsJobs.Server.csproj.user
Normal file
13
src/JobsJobsJobs/Server/JobsJobsJobs.Server.csproj.user
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<RazorPage_SelectedScaffolderID>RazorPageScaffolder</RazorPage_SelectedScaffolderID>
|
||||
<RazorPage_SelectedScaffolderCategoryPath>root/Common/RazorPage</RazorPage_SelectedScaffolderCategoryPath>
|
||||
<ActiveDebugProfile>JobsJobsJobs.Server</ActiveDebugProfile>
|
||||
<Controller_SelectedScaffolderID>ApiControllerEmptyScaffolder</Controller_SelectedScaffolderID>
|
||||
<Controller_SelectedScaffolderCategoryPath>root/Common/Api</Controller_SelectedScaffolderCategoryPath>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebuggerFlavor>ProjectDebugger</DebuggerFlavor>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
34
src/JobsJobsJobs/Server/Models/MastodonAccount.cs
Normal file
34
src/JobsJobsJobs/Server/Models/MastodonAccount.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace JobsJobsJobs.Server.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// The variables we need from the account information we get from No Agenda Social
|
||||
/// </summary>
|
||||
public class MastodonAccount
|
||||
{
|
||||
/// <summary>
|
||||
/// The user name (what we store as naUser)
|
||||
/// </summary>
|
||||
[JsonPropertyName("username")]
|
||||
public string Username { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// The account name; will be the same as username for local (non-federated) accounts
|
||||
/// </summary>
|
||||
[JsonPropertyName("acct")]
|
||||
public string AccountName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// The user's display name as it currently shows on No Agenda Social
|
||||
/// </summary>
|
||||
[JsonPropertyName("display_name")]
|
||||
public string DisplayName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// The user's profile URL
|
||||
/// </summary>
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = "";
|
||||
}
|
||||
}
|
||||
42
src/JobsJobsJobs/Server/Pages/Error.cshtml
Normal file
42
src/JobsJobsJobs/Server/Pages/Error.cshtml
Normal file
@@ -0,0 +1,42 @@
|
||||
@page
|
||||
@model JobsJobsJobs.Server.Pages.ErrorModel
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Error</title>
|
||||
<link href="~/css/bootstrap/bootstrap.min.css" rel="stylesheet" />
|
||||
<link href="~/css/app.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="main">
|
||||
<div class="content px-4">
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (Model.ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@Model.RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
32
src/JobsJobsJobs/Server/Pages/Error.cshtml.cs
Normal file
32
src/JobsJobsJobs/Server/Pages/Error.cshtml.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Pages
|
||||
{
|
||||
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
||||
[IgnoreAntiforgeryToken]
|
||||
public class ErrorModel : PageModel
|
||||
{
|
||||
public string RequestId { get; set; } = default!;
|
||||
|
||||
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
private readonly ILogger<ErrorModel> _logger;
|
||||
|
||||
public ErrorModel(ILogger<ErrorModel> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/JobsJobsJobs/Server/Pages/_Host.cshtml
Normal file
29
src/JobsJobsJobs/Server/Pages/_Host.cshtml
Normal file
@@ -0,0 +1,29 @@
|
||||
@page
|
||||
@model JobsJobsJobs.Server.Pages._HostModel
|
||||
@{
|
||||
}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Jobs, Jobs, Jobs</title>
|
||||
<base href="/" />
|
||||
<link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
|
||||
<link href="css/app.css" rel="stylesheet" />
|
||||
<link href="JobsJobsJobs.Client.styles.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<component type="typeof(JobsJobsJobs.Client.App)" render-mode="WebAssemblyPrerendered" />
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="reload">Reload</a>
|
||||
<a class="dismiss">🗙</a>
|
||||
</div>
|
||||
<script src="_framework/blazor.webassembly.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
16
src/JobsJobsJobs/Server/Pages/_Host.cshtml.cs
Normal file
16
src/JobsJobsJobs/Server/Pages/_Host.cshtml.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JobsJobsJobs.Server.Pages
|
||||
{
|
||||
public class _HostModel : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/JobsJobsJobs/Server/Program.cs
Normal file
26
src/JobsJobsJobs/Server/Program.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server
|
||||
{
|
||||
public class Program
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
CreateHostBuilder(args).Build().Run();
|
||||
}
|
||||
|
||||
public static IHostBuilder CreateHostBuilder(string[] args) =>
|
||||
Host.CreateDefaultBuilder(args)
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseStartup<Startup>();
|
||||
});
|
||||
}
|
||||
}
|
||||
30
src/JobsJobsJobs/Server/Properties/launchSettings.json
Normal file
30
src/JobsJobsJobs/Server/Properties/launchSettings.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:49363",
|
||||
"sslPort": 44308
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"JobsJobsJobs.Server": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": "true",
|
||||
"launchBrowser": true,
|
||||
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||
"applicationUrl": "https://localhost:3005;http://localhost:5000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
90
src/JobsJobsJobs/Server/Startup.cs
Normal file
90
src/JobsJobsJobs/Server/Startup.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.HttpsPolicy;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.SystemTextJson;
|
||||
using Npgsql;
|
||||
using System.Text;
|
||||
|
||||
namespace JobsJobsJobs.Server
|
||||
{
|
||||
public class Startup
|
||||
{
|
||||
public Startup(IConfiguration configuration)
|
||||
{
|
||||
Configuration = configuration;
|
||||
}
|
||||
|
||||
public IConfiguration Configuration { get; }
|
||||
|
||||
// This method gets called by the runtime. Use this method to add services to the container.
|
||||
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
// TODO: configure JSON serialization for NodaTime
|
||||
services.AddScoped(_ => new NpgsqlConnection(Configuration.GetConnectionString("JobsDb")));
|
||||
services.AddSingleton<IClock>(SystemClock.Instance);
|
||||
services.AddLogging();
|
||||
services.AddControllersWithViews();
|
||||
services.AddRazorPages()
|
||||
.AddJsonOptions(options =>
|
||||
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb));
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
}).AddJwtBearer(options =>
|
||||
{
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = "https://jobsjobs.jobs",
|
||||
ValidIssuer = "https://jobsjobs.jobs",
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
|
||||
Configuration.GetSection("Auth")["ServerSecret"]))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
|
||||
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
|
||||
{
|
||||
NpgsqlConnection.GlobalTypeMapper.UseNodaTime();
|
||||
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
app.UseDeveloperExceptionPage();
|
||||
app.UseWebAssemblyDebugging();
|
||||
}
|
||||
else
|
||||
{
|
||||
app.UseExceptionHandler("/Error");
|
||||
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseBlazorFrameworkFiles();
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.UseEndpoints(endpoints =>
|
||||
{
|
||||
endpoints.MapRazorPages();
|
||||
endpoints.MapControllers();
|
||||
endpoints.MapFallbackToFile("index.html");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/JobsJobsJobs/Server/appsettings.json
Normal file
10
src/JobsJobsJobs/Server/appsettings.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Reference in New Issue
Block a user