Help wanted (#23)

Create a "help wanted" area of the site (#14)
This commit was merged in pull request #23.
This commit is contained in:
2021-08-31 21:16:43 -04:00
committed by GitHub
parent e30e28c279
commit 909a0982e0
188 changed files with 20918 additions and 5802 deletions

View File

@@ -1,12 +0,0 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "5.0.1",
"commands": [
"dotnet-ef"
]
}
}
}

View File

@@ -1,108 +0,0 @@
using JobsJobsJobs.Server.Data;
using JobsJobsJobs.Shared;
using JobsJobsJobs.Shared.Api;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using NodaTime;
using System.Security.Claims;
using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Areas.Api.Controllers
{
/// <summary>
/// API controller for citizen information
/// </summary>
[Route("api/[controller]")]
[ApiController]
public class CitizenController : ControllerBase
{
/// <summary>
/// Authorization configuration section
/// </summary>
private readonly IConfigurationSection _config;
/// <summary>
/// NodaTime clock
/// </summary>
private readonly IClock _clock;
/// <summary>
/// The data context to use for this request
/// </summary>
private readonly JobsDbContext _db;
/// <summary>
/// Constructor
/// </summary>
/// <param name="config">The authorization configuration section</param>
/// <param name="clock">The NodaTime clock instance</param>
/// <param name="db">The data context to use for this request</param>
public CitizenController(IConfiguration config, IClock clock, JobsDbContext db)
{
_config = config.GetSection("Auth");
_clock = clock;
_db = db;
}
/// <summary>
/// The current citizen ID
/// </summary>
private CitizenId CurrentCitizenId => CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value);
[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();
var citizen = await _db.FindCitizenByNAUser(account.Username);
if (citizen == null)
{
citizen = new Citizen(await CitizenId.Create(), account.Username,
string.IsNullOrWhiteSpace(account.DisplayName) ? null : account.DisplayName, null, account.Url,
now, now);
await _db.AddCitizen(citizen);
}
else
{
citizen = citizen with
{
DisplayName = string.IsNullOrWhiteSpace(account.DisplayName) ? null : account.DisplayName,
LastSeenOn = now
};
_db.UpdateCitizen(citizen);
}
await _db.SaveChangesAsync();
// Step 3 - Generate JWT
var jwt = Auth.CreateJwt(citizen, _config);
return new JsonResult(new LogOnSuccess(jwt, citizen.Id.ToString(), citizen.CitizenName));
}
[Authorize]
[HttpGet("get/{id}")]
public async Task<IActionResult> GetCitizenById([FromRoute] string id)
{
var citizen = await _db.FindCitizenById(CitizenId.Parse(id));
return citizen == null ? NotFound() : Ok(citizen);
}
[Authorize]
[HttpDelete("")]
public async Task<IActionResult> Remove()
{
await _db.DeleteCitizen(CurrentCitizenId);
await _db.SaveChangesAsync();
return Ok();
}
}
}

View File

@@ -1,32 +0,0 @@
using JobsJobsJobs.Server.Data;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Areas.Api.Controllers
{
/// <summary>
/// API endpoint for continent information
/// </summary>
[Route("api/[controller]")]
[ApiController]
public class ContinentController : ControllerBase
{
/// <summary>
/// The data context to use for this request
/// </summary>
private readonly JobsDbContext _db;
/// <summary>
/// Constructor
/// </summary>
/// <param name="db">The data context to use for this request</param>
public ContinentController(JobsDbContext db)
{
_db = db;
}
[HttpGet("all")]
public async Task<IActionResult> All() =>
Ok(await _db.AllContinents());
}
}

View File

@@ -1,147 +0,0 @@
using JobsJobsJobs.Server.Data;
using JobsJobsJobs.Shared;
using JobsJobsJobs.Shared.Api;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NodaTime;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Areas.Api.Controllers
{
/// <summary>
/// API controller for employment profile information
/// </summary>
[Route("api/[controller]")]
[Authorize]
[ApiController]
public class ProfileController : ControllerBase
{
/// <summary>
/// The data context
/// </summary>
private readonly JobsDbContext _db;
/// <summary>
/// The NodaTime clock instance
/// </summary>
private readonly IClock _clock;
/// <summary>
/// Constructor
/// </summary>
/// <param name="db">The data context to use for this request</param>
/// <param name="clock">The clock instance to use for this request</param>
public ProfileController(JobsDbContext db, IClock clock)
{
_db = db;
_clock = clock;
}
/// <summary>
/// The current citizen ID
/// </summary>
private CitizenId CurrentCitizenId => CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value);
// This returns 204 to indicate that there is no profile data for the current citizen (if, of course, that is
// the case). The version where an ID is specified returns 404, which is an error condition, as it should not
// occur unless someone is messing with a URL.
[HttpGet("")]
public async Task<IActionResult> Get()
{
var profile = await _db.FindProfileByCitizen(CurrentCitizenId);
return profile == null ? NoContent() : Ok(profile);
}
[HttpPost("save")]
public async Task<IActionResult> Save(ProfileForm form)
{
// Profile
var existing = await _db.FindProfileByCitizen(CurrentCitizenId);
var profile = existing == null
? new Profile(CurrentCitizenId, form.IsSeekingEmployment, form.IsPublic,
ContinentId.Parse(form.ContinentId), form.Region, form.RemoteWork, form.FullTime,
new MarkdownString(form.Biography), _clock.GetCurrentInstant(),
string.IsNullOrEmpty(form.Experience) ? null : new MarkdownString(form.Experience))
: existing with
{
SeekingEmployment = form.IsSeekingEmployment,
IsPublic = form.IsPublic,
ContinentId = ContinentId.Parse(form.ContinentId),
Region = form.Region,
RemoteWork = form.RemoteWork,
FullTime = form.FullTime,
Biography = new MarkdownString(form.Biography),
LastUpdatedOn = _clock.GetCurrentInstant(),
Experience = string.IsNullOrEmpty(form.Experience) ? null : new MarkdownString(form.Experience)
};
await _db.SaveProfile(profile);
// Skills
var skills = new List<Skill>();
foreach (var skill in form.Skills) {
skills.Add(new Skill(skill.Id.StartsWith("new") ? await SkillId.Create() : SkillId.Parse(skill.Id),
CurrentCitizenId, skill.Description, string.IsNullOrEmpty(skill.Notes) ? null : skill.Notes));
}
foreach (var skill in skills) await _db.SaveSkill(skill);
await _db.DeleteMissingSkills(CurrentCitizenId, skills.Select(s => s.Id));
// Real Name
_db.Update((await _db.FindCitizenById(CurrentCitizenId))!
with { RealName = string.IsNullOrWhiteSpace(form.RealName) ? null : form.RealName });
await _db.SaveChangesAsync();
return Ok();
}
[HttpGet("count")]
public async Task<IActionResult> GetProfileCount() =>
Ok(new Count(await _db.CountProfiles()));
[HttpGet("skill-count")]
public async Task<IActionResult> GetSkillCount() =>
Ok(new Count(await _db.CountSkillsByCitizen(CurrentCitizenId)));
[HttpGet("get/{id}")]
public async Task<IActionResult> GetProfileForCitizen([FromRoute] string id)
{
var profile = await _db.FindProfileByCitizen(CitizenId.Parse(id));
return profile == null ? NotFound() : Ok(profile);
}
[HttpGet("search")]
public async Task<IActionResult> Search([FromQuery] ProfileSearch search) =>
Ok(await _db.SearchProfiles(search));
[HttpGet("public-search")]
[AllowAnonymous]
public async Task<IActionResult> SearchPublic([FromQuery] PublicSearch search) =>
Ok(await _db.SearchPublicProfiles(search));
[HttpPatch("employment-found")]
public async Task<IActionResult> EmploymentFound()
{
var profile = await _db.FindProfileByCitizen(CurrentCitizenId);
if (profile == null) return NotFound();
var updated = profile with { SeekingEmployment = false };
_db.Update(updated);
await _db.SaveChangesAsync();
return Ok();
}
[HttpDelete("")]
public async Task<IActionResult> Remove()
{
await _db.DeleteProfileByCitizen(CurrentCitizenId);
await _db.SaveChangesAsync();
return Ok();
}
}
}

View File

@@ -1,79 +0,0 @@
using JobsJobsJobs.Server.Data;
using JobsJobsJobs.Shared;
using JobsJobsJobs.Shared.Api;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NodaTime;
using System.Security.Claims;
using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Areas.Api.Controllers
{
/// <summary>
/// API controller for success stories
/// </summary>
[Route("api/[controller]")]
[Authorize]
[ApiController]
public class SuccessController : Controller
{
/// <summary>
/// The data context
/// </summary>
private readonly JobsDbContext _db;
/// <summary>
/// The NodaTime clock instance
/// </summary>
private readonly IClock _clock;
/// <summary>
/// Constructor
/// </summary>
/// <param name="db">The data context to use for this request</param>
/// <param name="clock">The clock instance to use for this request</param>
public SuccessController(JobsDbContext db, IClock clock)
{
_db = db;
_clock = clock;
}
/// <summary>
/// The current citizen ID
/// </summary>
private CitizenId CurrentCitizenId => CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value);
[HttpGet("{id}")]
public async Task<IActionResult> Retrieve(string id) =>
Ok(await _db.FindSuccessById(SuccessId.Parse(id)));
[HttpPost("save")]
public async Task<IActionResult> Save([FromBody] StoryForm form)
{
if (form.Id == "new")
{
var story = new Success(await SuccessId.Create(), CurrentCitizenId, _clock.GetCurrentInstant(),
form.FromHere, string.IsNullOrWhiteSpace(form.Story) ? null : new MarkdownString(form.Story));
await _db.AddAsync(story);
}
else
{
var story = await _db.FindSuccessById(SuccessId.Parse(form.Id));
if (story == null) return NotFound();
var updated = story with
{
FromHere = form.FromHere,
Story = string.IsNullOrWhiteSpace(form.Story) ? null : new MarkdownString(form.Story)
};
_db.Update(updated);
}
await _db.SaveChangesAsync();
return Ok();
}
[HttpGet("list")]
public async Task<IActionResult> List() =>
Ok(await _db.AllStories());
}
}

View File

@@ -1,112 +0,0 @@
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 = $"{config["ReturnHost"]}/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.CitizenName),
}),
Expires = DateTime.UtcNow.AddHours(2),
Issuer = "https://noagendacareers.com",
Audience = "https://noagendacareers.com",
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["ServerSecret"])),
SecurityAlgorithms.HmacSha256Signature)
});
return tokenHandler.WriteToken(token);
}
}
}

View File

@@ -1,61 +0,0 @@
using JobsJobsJobs.Shared;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Data
{
/// <summary>
/// Extensions to JobsDbContext supporting the manipulation of citizens
/// </summary>
public static class CitizenExtensions
{
/// <summary>
/// Retrieve a citizen by their Jobs, Jobs, Jobs ID
/// </summary>
/// <param name="citizenId">The ID of the citizen to retrieve</param>
/// <returns>The citizen, or null if not found</returns>
public static async Task<Citizen?> FindCitizenById(this JobsDbContext db, CitizenId citizenId) =>
await db.Citizens.AsNoTracking()
.SingleOrDefaultAsync(c => c.Id == citizenId)
.ConfigureAwait(false);
/// <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 JobsDbContext db, string naUser) =>
await db.Citizens.AsNoTracking()
.SingleOrDefaultAsync(c => c.NaUser == naUser)
.ConfigureAwait(false);
/// <summary>
/// Add a citizen
/// </summary>
/// <param name="citizen">The citizen to be added</param>
public static async Task AddCitizen(this JobsDbContext db, Citizen citizen) =>
await db.Citizens.AddAsync(citizen).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 void UpdateCitizen(this JobsDbContext db, Citizen citizen) =>
db.Entry(citizen).State = EntityState.Modified;
/// <summary>
/// Delete a citizen
/// </summary>
/// <param name="citizenId">The ID of the citizen to be deleted</param>
/// <returns></returns>
public static async Task DeleteCitizen(this JobsDbContext db, CitizenId citizenId)
{
var id = citizenId.ToString();
await db.DeleteProfileByCitizen(citizenId).ConfigureAwait(false);
await db.Database.ExecuteSqlInterpolatedAsync($"DELETE FROM jjj.success WHERE citizen_id = {id}")
.ConfigureAwait(false);
await db.Database.ExecuteSqlInterpolatedAsync($"DELETE FROM jjj.citizen WHERE id = {id}")
.ConfigureAwait(false);
}
}
}

View File

@@ -1,31 +0,0 @@
using JobsJobsJobs.Shared;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Data
{
/// <summary>
/// Data extensions for manipulation of continent objects
/// </summary>
public static class ContinentExtensions
{
/// <summary>
/// Retrieve all continents
/// </summary>
/// <returns>All continents</returns>
public static async Task<IEnumerable<Continent>> AllContinents(this JobsDbContext db) =>
await db.Continents.AsNoTracking().OrderBy(c => c.Name).ToListAsync().ConfigureAwait(false);
/// <summary>
/// Retrieve a continent by its ID
/// </summary>
/// <param name="continentId">The ID of the continent to retrieve</param>
/// <returns>The continent matching the ID</returns>
public static async Task<Continent> FindContinentById(this JobsDbContext db, ContinentId continentId) =>
await db.Continents.AsNoTracking()
.SingleAsync(c => c.Id == continentId)
.ConfigureAwait(false);
}
}

View File

@@ -1,47 +0,0 @@
using JobsJobsJobs.Shared;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace JobsJobsJobs.Server.Data
{
/// <summary>
/// Converters used to translate between database and domain types
/// </summary>
public static class Converters
{
/// <summary>
/// Citizen ID converter
/// </summary>
public static readonly ValueConverter<CitizenId, string> CitizenIdConverter =
new(v => v.ToString(), v => CitizenId.Parse(v));
/// <summary>
/// Continent ID converter
/// </summary>
public static readonly ValueConverter<ContinentId, string> ContinentIdConverter =
new(v => v.ToString(), v => ContinentId.Parse(v));
/// <summary>
/// Markdown converter
/// </summary>
public static readonly ValueConverter<MarkdownString, string> MarkdownStringConverter =
new(v => v.Text, v => new MarkdownString(v));
/// <summary>
/// Markdown converter for possibly-null values
/// </summary>
public static readonly ValueConverter<MarkdownString?, string?> OptionalMarkdownStringConverter =
new(v => v == null ? null : v.Text, v => v == null ? null : new MarkdownString(v));
/// <summary>
/// Skill ID converter
/// </summary>
public static readonly ValueConverter<SkillId, string> SkillIdConverter =
new(v => v.ToString(), v => SkillId.Parse(v));
/// <summary>
/// Success ID converter
/// </summary>
public static readonly ValueConverter<SuccessId, string> SuccessIdConverter =
new(v => v.ToString(), v => SuccessId.Parse(v));
}
}

View File

@@ -1,119 +0,0 @@
using JobsJobsJobs.Shared;
using Microsoft.EntityFrameworkCore;
namespace JobsJobsJobs.Server.Data
{
/// <summary>
/// Data context for Jobs, Jobs, Jobs
/// </summary>
public class JobsDbContext : DbContext
{
/// <summary>
/// Citizens (users known to us)
/// </summary>
public DbSet<Citizen> Citizens { get; set; } = default!;
/// <summary>
/// Continents (large land masses - 7 of them!)
/// </summary>
public DbSet<Continent> Continents { get; set; } = default!;
/// <summary>
/// Employment profiles
/// </summary>
public DbSet<Profile> Profiles { get; set; } = default!;
/// <summary>
/// Skills held by citizens of Gitmo Nation
/// </summary>
public DbSet<Skill> Skills { get; set; } = default!;
/// <summary>
/// Success stories from the site
/// </summary>
public DbSet<Success> Successes { get; set; } = default!;
/// <summary>
/// Constructor
/// </summary>
/// <param name="options">The options to use to configure this instance</param>
public JobsDbContext(DbContextOptions<JobsDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Citizen>(m =>
{
m.ToTable("citizen", "jjj").HasKey(e => e.Id);
m.Property(e => e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.CitizenIdConverter);
m.Property(e => e.NaUser).HasColumnName("na_user").IsRequired().HasMaxLength(50);
m.Property(e => e.DisplayName).HasColumnName("display_name").HasMaxLength(255);
m.Property(e => e.ProfileUrl).HasColumnName("profile_url").IsRequired().HasMaxLength(1_024);
m.Property(e => e.JoinedOn).HasColumnName("joined_on").IsRequired();
m.Property(e => e.LastSeenOn).HasColumnName("last_seen_on").IsRequired();
m.Property(e => e.RealName).HasColumnName("real_name").HasMaxLength(255);
m.HasIndex(e => e.NaUser).IsUnique();
m.Ignore(e => e.CitizenName);
});
modelBuilder.Entity<Continent>(m =>
{
m.ToTable("continent", "jjj").HasKey(e => e.Id);
m.Property(e => e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.ContinentIdConverter);
m.Property(e => e.Name).HasColumnName("name").IsRequired().HasMaxLength(255);
});
modelBuilder.Entity<Profile>(m =>
{
m.ToTable("profile", "jjj").HasKey(e => e.Id);
m.Property(e => e.Id).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.CitizenIdConverter);
m.Property(e => e.SeekingEmployment).HasColumnName("seeking_employment").IsRequired();
m.Property(e => e.IsPublic).HasColumnName("is_public").IsRequired();
m.Property(e => e.ContinentId).HasColumnName("continent_id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.ContinentIdConverter);
m.Property(e => e.Region).HasColumnName("region").IsRequired().HasMaxLength(255);
m.Property(e => e.RemoteWork).HasColumnName("remote_work").IsRequired();
m.Property(e => e.FullTime).HasColumnName("full_time").IsRequired();
m.Property(e => e.Biography).HasColumnName("biography").IsRequired()
.HasConversion(Converters.MarkdownStringConverter);
m.Property(e => e.LastUpdatedOn).HasColumnName("last_updated_on").IsRequired();
m.Property(e => e.Experience).HasColumnName("experience")
.HasConversion(Converters.OptionalMarkdownStringConverter);
m.HasOne(e => e.Continent)
.WithMany()
.HasForeignKey(e => e.ContinentId);
m.HasMany(e => e.Skills)
.WithOne()
.HasForeignKey(e => e.CitizenId);
});
modelBuilder.Entity<Skill>(m =>
{
m.ToTable("skill", "jjj").HasKey(e => e.Id);
m.Property(e => e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.SkillIdConverter);
m.Property(e => e.CitizenId).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.CitizenIdConverter);
m.Property(e => e.Description).HasColumnName("skill").IsRequired().HasMaxLength(100);
m.Property(e => e.Notes).HasColumnName("notes").HasMaxLength(100);
});
modelBuilder.Entity<Success>(m =>
{
m.ToTable("success", "jjj").HasKey(e => e.Id);
m.Property(e => e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.SuccessIdConverter);
m.Property(e => e.CitizenId).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
.HasConversion(Converters.CitizenIdConverter);
m.Property(e => e.RecordedOn).HasColumnName("recorded_on").IsRequired();
m.Property(e => e.FromHere).HasColumnName("from_here").IsRequired();
m.Property(e => e.Story).HasColumnName("story")
.HasConversion(Converters.OptionalMarkdownStringConverter);
});
}
}
}

View File

@@ -1,206 +0,0 @@
using JobsJobsJobs.Shared;
using JobsJobsJobs.Shared.Api;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Data
{
/// <summary>
/// Extensions to JobsDbContext to support manipulation of profiles
/// </summary>
public static class ProfileExtensions
{
/// <summary>
/// Retrieve an employment profile by a citizen ID
/// </summary>
/// <param name="citizenId">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 JobsDbContext db, CitizenId citizenId) =>
await db.Profiles.AsNoTracking()
.Include(p => p.Continent)
.Include(p => p.Skills)
.SingleOrDefaultAsync(p => p.Id == citizenId)
.ConfigureAwait(false);
/// <summary>
/// Save a profile
/// </summary>
/// <param name="profile">The profile to be saved</param>
public static async Task SaveProfile(this JobsDbContext db, Profile profile)
{
if (await db.Profiles.CountAsync(p => p.Id == profile.Id).ConfigureAwait(false) == 0)
{
await db.AddAsync(profile).ConfigureAwait(false);
}
else
{
db.Entry(profile).State = EntityState.Modified;
}
}
/// <summary>
/// Save a skill
/// </summary>
/// <param name="skill">The skill to be saved</param>
public static async Task SaveSkill(this JobsDbContext db, Skill skill)
{
if (await db.Skills.CountAsync(s => s.Id == skill.Id).ConfigureAwait(false) == 0)
{
await db.Skills.AddAsync(skill).ConfigureAwait(false);
}
else
{
db.Entry(skill).State = EntityState.Modified;
}
}
/// <summary>
/// Delete any skills that are not in the list of current skill IDs
/// </summary>
/// <param name="citizenId">The ID of the citizen to whom the skills belong</param>
/// <param name="ids">The IDs of their current skills</param>
public static async Task DeleteMissingSkills(this JobsDbContext db, CitizenId citizenId,
IEnumerable<SkillId> ids)
{
if (!ids.Any()) return;
db.Skills.RemoveRange(await db.Skills.AsNoTracking()
.Where(s => s.CitizenId == citizenId && !ids.Contains(s.Id)).ToListAsync()
.ConfigureAwait(false));
}
/// <summary>
/// Get a count of the citizens with profiles
/// </summary>
/// <returns>The number of citizens with profiles</returns>
public static async Task<int> CountProfiles(this JobsDbContext db) =>
await db.Profiles.CountAsync().ConfigureAwait(false);
/// <summary>
/// Count the skills for the given citizen
/// </summary>
/// <param name="citizenId">The ID of the citizen whose skills should be counted</param>
/// <returns>The count of skills for the given citizen</returns>
public static async Task<int> CountSkillsByCitizen(this JobsDbContext db, CitizenId citizenId) =>
await db.Skills.CountAsync(s => s.CitizenId == citizenId).ConfigureAwait(false);
/// <summary>
/// Search profiles by the given criteria
/// </summary>
/// <param name="search">The search parameters</param>
/// <returns>The information for profiles matching the criteria</returns>
public static async Task<IEnumerable<ProfileSearchResult>> SearchProfiles(this JobsDbContext db,
ProfileSearch search)
{
var query = db.Profiles
.Join(db.Citizens, p => p.Id, c => c.Id, (p, c) => new { Profile = p, Citizen = c });
var useIds = false;
var citizenIds = new List<CitizenId>();
if (!string.IsNullOrEmpty(search.ContinentId))
{
query = query.Where(it => it.Profile.ContinentId == ContinentId.Parse(search.ContinentId));
}
if (!string.IsNullOrEmpty(search.RemoteWork))
{
query = query.Where(it => it.Profile.RemoteWork == (search.RemoteWork == "yes"));
}
if (!string.IsNullOrEmpty(search.Skill))
{
useIds = true;
citizenIds.AddRange(await db.Skills
.Where(s => s.Description.ToLower().Contains(search.Skill.ToLower()))
.Select(s => s.CitizenId)
.ToListAsync().ConfigureAwait(false));
}
if (!string.IsNullOrEmpty(search.BioExperience))
{
useIds = true;
citizenIds.AddRange(await db.Profiles
.FromSqlRaw("SELECT citizen_id FROM profile WHERE biography ILIKE {0} OR experience ILIKE {0}",
$"%{search.BioExperience}%")
.Select(p => p.Id)
.ToListAsync().ConfigureAwait(false));
}
if (useIds)
{
query = query.Where(it => citizenIds.Contains(it.Citizen.Id));
}
return await query.Select(x => new ProfileSearchResult(x.Citizen.Id, x.Citizen.CitizenName,
x.Profile.SeekingEmployment, x.Profile.RemoteWork, x.Profile.FullTime, x.Profile.LastUpdatedOn))
.ToListAsync().ConfigureAwait(false);
}
/// <summary>
/// Search public profiles by the given criteria
/// </summary>
/// <param name="search">The search parameters</param>
/// <returns>The information for profiles matching the criteria</returns>
public static async Task<IEnumerable<PublicSearchResult>> SearchPublicProfiles(this JobsDbContext db,
PublicSearch search)
{
var query = db.Profiles
.Include(it => it.Continent)
.Include(it => it.Skills)
.Where(it => it.IsPublic);
var useIds = false;
var citizenIds = new List<CitizenId>();
if (!string.IsNullOrEmpty(search.ContinentId))
{
query = query.Where(it => it.ContinentId == ContinentId.Parse(search.ContinentId));
}
if (!string.IsNullOrEmpty(search.Region))
{
query = query.Where(it => it.Region.ToLower().Contains(search.Region.ToLower()));
}
if (!string.IsNullOrEmpty(search.RemoteWork))
{
query = query.Where(it => it.RemoteWork == (search.RemoteWork == "yes"));
}
if (!string.IsNullOrEmpty(search.Skill))
{
useIds = true;
citizenIds.AddRange(await db.Skills
.Where(s => s.Description.ToLower().Contains(search.Skill.ToLower()))
.Select(s => s.CitizenId)
.ToListAsync().ConfigureAwait(false));
}
if (useIds)
{
query = query.Where(it => citizenIds.Contains(it.Id));
}
return await query.Select(x => new PublicSearchResult(x.Continent!.Name, x.Region, x.RemoteWork,
x.Skills.Select(sk => $"{sk.Description} ({sk.Notes})")))
.ToListAsync().ConfigureAwait(false);
}
/// <summary>
/// Delete skills and profile for the given citizen
/// </summary>
/// <param name="citizenId">The ID of the citizen whose profile should be deleted</param>
public static async Task DeleteProfileByCitizen(this JobsDbContext db, CitizenId citizenId)
{
var id = citizenId.ToString();
await db.Database.ExecuteSqlInterpolatedAsync($"DELETE FROM jjj.skill WHERE citizen_id = {id}")
.ConfigureAwait(false);
await db.Database.ExecuteSqlInterpolatedAsync($"DELETE FROM jjj.profile WHERE citizen_id = {id}")
.ConfigureAwait(false);
}
}
}

View File

@@ -1,35 +0,0 @@
using JobsJobsJobs.Shared;
using JobsJobsJobs.Shared.Api;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Data
{
/// <summary>
/// Extensions to JobsDbContext to support manipulation of success stories
/// </summary>
public static class SuccessExtensions
{
/// <summary>
/// Get a success story by its ID
/// </summary>
/// <param name="id">The ID of the story to retrieve</param>
/// <returns>The success story, if found</returns>
public static async Task<Success?> FindSuccessById(this JobsDbContext db, SuccessId id) =>
await db.Successes.AsNoTracking().SingleOrDefaultAsync(s => s.Id == id).ConfigureAwait(false);
/// <summary>
/// Get a list of success stories, with the information needed for the list page
/// </summary>
/// <returns>A list of success stories, citizen names, and dates</returns>
public static async Task<IEnumerable<StoryEntry>> AllStories(this JobsDbContext db) =>
await db.Successes
.Join(db.Citizens, s => s.CitizenId, c => c.Id, (s, c) => new { Success = s, Citizen = c })
.OrderByDescending(it => it.Success.RecordedOn)
.Select(it => new StoryEntry(it.Success.Id, it.Citizen.Id, it.Citizen.CitizenName,
it.Success.RecordedOn, it.Success.FromHere, it.Success.Story != null))
.ToListAsync().ConfigureAwait(false);
}
}

View File

@@ -1,27 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<UserSecretsId>553960ef-0c79-47d4-98d8-9ca1708e558f</UserSecretsId>
</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.EntityFrameworkCore.PostgreSQL" Version="5.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="5.0.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>

View File

@@ -1,14 +0,0 @@
<?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>MvcControllerEmptyScaffolder</Controller_SelectedScaffolderID>
<Controller_SelectedScaffolderCategoryPath>root/Common/MVC/Controller</Controller_SelectedScaffolderCategoryPath>
<NameOfLastUsedPublishProfile>C:\Users\danie\Documents\sandbox\jobs-jobs-jobs\src\JobsJobsJobs\Server\Properties\PublishProfiles\FolderProfile.pubxml</NameOfLastUsedPublishProfile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebuggerFlavor>ProjectDebugger</DebuggerFlavor>
</PropertyGroup>
</Project>

View File

@@ -1,34 +0,0 @@
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; } = "";
}
}

View File

@@ -1,42 +0,0 @@
@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>

View File

@@ -1,32 +0,0 @@
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;
}
}
}

View File

@@ -1,45 +0,0 @@
@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" />
<link href="_content/Blazored.Toast/blazored-toast.min.css" rel="stylesheet">
</head>
<body>
<div id="app">
<component type="typeof(JobsJobsJobs.Client.App)" render-mode="WebAssemblyPrerendered" />
</div>
<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>
<script>
var Audio = {
play(audio) {
document.getElementById(audio).play()
}
}
function setPageTitle(theTitle) {
document.title = theTitle
}
function getTimeZone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone
}
</script>
</body>
</html>

View File

@@ -1,16 +0,0 @@
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()
{
}
}
}

View File

@@ -1,26 +0,0 @@
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>();
});
}
}

View File

@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<DeleteExistingFiles>True</DeleteExistingFiles>
<ExcludeApp_Data>False</ExcludeApp_Data>
<LaunchSiteAfterPublish>True</LaunchSiteAfterPublish>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<PublishProvider>FileSystem</PublishProvider>
<PublishUrl>bin\Release\net5.0\publish\</PublishUrl>
<WebPublishMethod>FileSystem</WebPublishMethod>
<SiteUrlToLaunchAfterPublish />
<TargetFramework>net5.0</TargetFramework>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<PublishSingleFile>True</PublishSingleFile>
<PublishTrimmed>True</PublishTrimmed>
<ProjectGuid>35aeecbf-489d-41c5-9ba3-6e43ad7a8196</ProjectGuid>
<SelfContained>true</SelfContained>
</PropertyGroup>
</Project>

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<_PublishTargetUrl>C:\Users\danie\Documents\sandbox\jobs-jobs-jobs\src\JobsJobsJobs\Server\bin\Release\net5.0\publish\</_PublishTargetUrl>
<History>True|2021-06-15T02:08:33.4261507Z;True|2021-06-14T21:58:04.2622487-04:00;True|2021-03-16T19:34:57.2747439-04:00;</History>
</PropertyGroup>
</Project>

View File

@@ -1,30 +0,0 @@
{
"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"
}
}
}
}

View File

@@ -1,107 +0,0 @@
using Blazored.Toast;
using JobsJobsJobs.Server.Data;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
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;
using System.Threading.Tasks;
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.AddDbContext<JobsDbContext>(options =>
{
options.UseNpgsql(Configuration.GetConnectionString("JobsDb"), o => o.UseNodaTime());
#if DEBUG
options.LogTo(System.Console.WriteLine, Microsoft.Extensions.Logging.LogLevel.Information);
#endif
});
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddLogging();
services.AddControllersWithViews();
services.AddRazorPages()
.AddJsonOptions(options =>
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb));
services.AddBlazoredToast();
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://noagendacareers.com",
ValidIssuer = "https://noagendacareers.com",
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();
static Task send404(HttpContext context)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return Task.FromResult(0);
}
app.UseEndpoints(endpoints =>
{
endpoints.MapRazorPages();
endpoints.MapControllers();
endpoints.MapFallback("api/{**slug}", send404);
endpoints.MapFallbackToPage("{**slug}", "/_Host");
});
}
}
}

View File

@@ -1,14 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Auth": {
"ApiUrl": "https://noagendasocial.com/api/v1/",
"ReturnHost": "https://noagendacareers.com"
},
"AllowedHosts": "*"
}