(feature): registration page init

This commit is contained in:
Maksym Sadovnychyy 2024-06-19 18:12:27 +02:00
parent 2dfc7259fb
commit e321a3237f
22 changed files with 508 additions and 168 deletions

View File

@ -1,25 +1,21 @@
enum ApiRoutes {
CACHE_GET_ACCOUNTS = `api/Cache/GetAccounts`,
CACHE_GET_CONTACTS = `api/Cache/GetContacts/{accountId}`,
CACHE_ADD_CONTACT = `api/Cache/AddContact/{accountId}`,
CACHE_DELETE_CONTACT = `api/Cache/DeleteContact/{accountId}?contact={contact}`,
CACHE_ACCOUNTS = 'api/cache/accounts',
CACHE_ACCOUNT = 'api/cache/account/{accountId}',
CACHE_ACCOUNT_CONTACTS = 'api/cache/account/{accountId}/contacts',
CACHE_ACCOUNT_CONTACT = 'api/cache/account/{accountId}/contacts/{index}',
CACHE_ACCOUNT_HOSTNAMES = 'api/cache/account/{accountId}/hostnames',
CACHE_GET_HOSTNAMES = `api/Cache/GetHostnames/{accountId}`,
// TODO: here is different flow via CertsFlowController, cache update is the result of add order and invalidate cert
// CACHE_ADD_HOSTNAME = `api/Cache/AddHostname/{accountId}`,
// CACHE_DELETE_HOSTNAME = `api/Cache/DeleteHostname/{accountId}?hostname={hostname}`,
CERTS_FLOW_CONFIGURE_CLIENT = `api/CertsFlow/ConfigureClient`,
CERTS_FLOW_TERMS_OF_SERVICE = `api/CertsFlow/TermsOfService/{sessionId}`,
CERTS_FLOW_INIT = `api/CertsFlow/Init/{sessionId}/{accountId}`,
CERTS_FLOW_NEW_ORDER = `api/CertsFlow/NewOrder/{sessionId}`,
CERTS_FLOW_GET_ORDER = `api/CertsFlow/GetOrder/{sessionId}`,
CERTS_FLOW_GET_CERTIFICATES = `api/CertsFlow/GetCertificates/{sessionId}`,
CERTS_FLOW_APPLY_CERTIFICATES = `api/CertsFlow/ApplyCertificates/{sessionId}`,
CERTS_FLOW_HOSRS_WITH_UPCOMING_SSL_EXPIRY = `api/CertsFlow/HostsWithUpcomingSslExpiry/{sessionId}`
// CERTS_FLOW_CONFIGURE_CLIENT = `api/CertsFlow/ConfigureClient`,
// CERTS_FLOW_TERMS_OF_SERVICE = `api/CertsFlow/TermsOfService/{sessionId}`,
// CERTS_FLOW_INIT = `api/CertsFlow/Init/{sessionId}/{accountId}`,
// CERTS_FLOW_NEW_ORDER = `api/CertsFlow/NewOrder/{sessionId}`,
// CERTS_FLOW_GET_ORDER = `api/CertsFlow/GetOrder/{sessionId}`,
// CERTS_FLOW_GET_CERTIFICATES = `api/CertsFlow/GetCertificates/{sessionId}`,
// CERTS_FLOW_APPLY_CERTIFICATES = `api/CertsFlow/ApplyCertificates/{sessionId}`,
// CERTS_FLOW_HOSRS_WITH_UPCOMING_SSL_EXPIRY = `api/CertsFlow/HostsWithUpcomingSslExpiry/{sessionId}`
}
const GetApiRoute = (route: ApiRoutes, ...args: string[]): string => {
@ -27,7 +23,7 @@ const GetApiRoute = (route: ApiRoutes, ...args: string[]): string => {
args.forEach(arg => {
result = result.replace(/{.*?}/, arg);
});
return 'http://localhost:5000/' + result;
return `http://localhost:5000/${result}`;
}

View File

@ -1,16 +1,16 @@
"use client" // Add this line
"use client"
import React, { FC, useState, useEffect, useRef } from 'react'
import './globals.css'
import { SideMenu } from '@/components/sidemenu'
import { TopMenu } from '@/components/topmenu'
import { Footer } from '@/components/footer'
import { OffCanvas } from '@/components/offcanvas'
import { Loader } from '@/components/loader' // Import the Loader component
import { Loader } from '@/components/loader'
import { Metadata } from 'next'
import { Toast } from '@/components/toast'
import { Provider } from 'react-redux'
import { store } from '@/redux/store'
import './globals.css'
const metadata: Metadata = {
title: "Create Next App",
@ -20,7 +20,6 @@ const metadata: Metadata = {
const Layout: FC<{ children: React.ReactNode }> = ({ children }) => {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const [isManuallyCollapsed, setManuallyCollapsed] = useState(false)
//const [isLoading, setIsLoading] = useState(true) // State to control the loader visibility
const init = useRef(false)
@ -43,7 +42,6 @@ const Layout: FC<{ children: React.ReactNode }> = ({ children }) => {
} else {
if (isManuallyCollapsed) return
// Reset manualCollapse if the window is resized to a state that should automatically collapse/expand the sidebar
if (window.innerWidth > 768 && isSidebarCollapsed) {
setIsSidebarCollapsed(false)
} else if (window.innerWidth <= 768 && !isSidebarCollapsed) {
@ -54,7 +52,7 @@ const Layout: FC<{ children: React.ReactNode }> = ({ children }) => {
useEffect(() => {
if (!init.current) {
handleResize() // Set the initial state based on the current window width
handleResize()
init.current = true
}
@ -72,20 +70,23 @@ const Layout: FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<html lang="en">
<body className="h-screen overflow-hidden">
<body className="h-screen overflow-hidden flex flex-col">
<Provider store={store}>
<Loader />
<div className="flex h-full">
<div className="flex flex-1 overflow-hidden">
<SideMenu isCollapsed={isSidebarCollapsed} toggleSidebar={manuallyToggleSidebar} />
<div className="flex flex-col flex-1">
<div className="flex flex-col flex-1 overflow-hidden">
<TopMenu onToggleOffCanvas={toggleOffCanvas} />
<main className="flex-1 p-4 transition-transform duration-300">
<main className="flex-1 p-4 overflow-y-auto">
{children}
</main>
<Footer />
<Footer className="flex-shrink-0" />
</div>
</div>
<OffCanvas isOpen={isOffCanvasOpen} onClose={toggleOffCanvas} />
<Toast />
</Provider>

View File

@ -1,14 +1,12 @@
"use client"
import { ApiRoutes, GetApiRoute } from "@/ApiRoutes"
import { GetAccountsResponse } from "@/models/letsEncryptServer/cache/GetAccountsResponse"
import { GetContactsResponse } from "@/models/letsEncryptServer/cache/GetContactsResponse"
import { GetHostnamesResponse } from "@/models/letsEncryptServer/cache/GetHostnamesResponse"
import { httpService } from "@/services/httpService"
import { FormEvent, useEffect, useRef, useState } from "react"
import { useValidation, isValidEmail, isValidHostname } from "@/hooks/useValidation"
import { CustomButton, CustomInput } from "@/controls"
import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid"
import { GetAccountResponse } from "@/models/letsEncryptServer/cache/responses/GetAccountResponse"
interface CacheAccountHostname {
hostname: string
@ -32,39 +30,44 @@ export default function Page() {
value: newContact,
error: contactError,
handleChange: handleContactChange
} = useValidation("", isValidEmail, "Invalid email format.")
} = useValidation({
initialValue:"",
validateFn: isValidEmail,
errorMessage: "Invalid email format."
})
const {
value: newHostname,
error: hostnameError,
handleChange: handleHostnameChange
} = useValidation("", isValidHostname, "Invalid hostname format.")
} = useValidation({
initialValue: "",
validateFn: isValidHostname,
errorMessage: "Invalid hostname format."})
const init = useRef(false)
useEffect(() => {
if (init.current) return
console.log("Fetching accounts")
const fetchAccounts = async () => {
const newAccounts: CacheAccount[] = []
const accountsResponse = await httpService.get<GetAccountsResponse>(GetApiRoute(ApiRoutes.CACHE_GET_ACCOUNTS))
for (const accountId of accountsResponse.accountIds) {
const [contactsResponse, hostnamesResponse] = await Promise.all([
httpService.get<GetContactsResponse>(GetApiRoute(ApiRoutes.CACHE_GET_CONTACTS, accountId)),
httpService.get<GetHostnamesResponse>(GetApiRoute(ApiRoutes.CACHE_GET_HOSTNAMES, accountId))
])
const accounts = await httpService.get<GetAccountResponse []>(GetApiRoute(ApiRoutes.CACHE_ACCOUNTS))
accounts?.forEach((account) => {
newAccounts.push({
accountId: accountId,
contacts: contactsResponse.contacts,
hostnames: hostnamesResponse.hostnames.map(h => ({
accountId: account.accountId,
contacts: account.contacts,
hostnames: account.hostnames.map(h => ({
hostname: h.hostname,
expires: new Date(h.expires),
isUpcomingExpire: h.isUpcomingExpire
})),
isEditMode: false
})
}
});
setAccounts(newAccounts)
setInitialAccounts(JSON.parse(JSON.stringify(newAccounts))) // Clone initial state
@ -92,7 +95,7 @@ export default function Page() {
if (account?.contacts.length ?? 0 < 1) return
// TODO: Remove from cache
httpService.delete(GetApiRoute(ApiRoutes.CACHE_DELETE_CONTACT, accountId, contact))
httpService.delete(GetApiRoute(ApiRoutes.CACHE_ACCOUNT_CONTACT, accountId, contact))
setAccounts(accounts.map(account =>
account.accountId === accountId
@ -192,7 +195,7 @@ export default function Page() {
return (
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold text-center mb-8">LetsEncrypt Client Dashboard</h1>
<h1 className="text-4xl font-bold text-center mb-8">LetsEncrypt Auto Renew</h1>
{
accounts.map(account => (
<div key={account.accountId} className="bg-white shadow-lg rounded-lg p-6 mb-6">

View File

@ -0,0 +1,209 @@
"use client"
import { ApiRoutes, GetApiRoute } from "@/ApiRoutes"
import { httpService } from "@/services/httpService"
import { FormEvent, useEffect, useRef, useState } from "react"
import { useValidation, isValidContact, isValidHostname } from "@/hooks/useValidation"
import { CustomButton, CustomInput } from "@/controls"
import { FaTrash, FaPlus } from "react-icons/fa"
import { GetAccountResponse } from "@/models/letsEncryptServer/cache/responses/GetAccountResponse"
interface CacheAccountHostname {
hostname: string
expires: Date
isUpcomingExpire: boolean
}
interface CacheAccount {
accountId: string
description?: string
contacts: string[]
hostnames: CacheAccountHostname[]
isEditMode: boolean
}
const RegisterPage = () => {
const [accounts, setAccounts] = useState<CacheAccount[]>([])
const [initialAccounts, setInitialAccounts] = useState<CacheAccount[]>([])
const [description, setDescription] = useState("")
const [contacts, setContacts] = useState<string[]>([])
const [hostnames, setHostnames] = useState<string[]>([])
const {
value: newContact,
error: contactError,
handleChange: handleContactChange,
reset: resetContact
} = useValidation({
initialValue: "",
validateFn: isValidContact,
errorMessage: "Invalid contact. Must be a valid email or phone number."
})
const {
value: newHostname,
error: hostnameError,
handleChange: handleHostnameChange,
reset: resetHostname
} = useValidation({
initialValue: "",
validateFn: isValidHostname,
errorMessage: "Invalid hostname format."
})
const init = useRef(false)
useEffect(() => {
if (init.current) return
const fetchAccounts = async () => {
const newAccounts: CacheAccount[] = []
const accounts = await httpService.get<GetAccountResponse[]>(GetApiRoute(ApiRoutes.CACHE_ACCOUNTS))
accounts?.forEach((account) => {
newAccounts.push({
accountId: account.accountId,
contacts: account.contacts,
hostnames: account.hostnames.map(h => ({
hostname: h.hostname,
expires: new Date(h.expires),
isUpcomingExpire: h.isUpcomingExpire
})),
isEditMode: false
})
})
setAccounts(newAccounts)
setInitialAccounts(JSON.parse(JSON.stringify(newAccounts))) // Clone initial state
}
fetchAccounts()
init.current = true
}, [])
const handleAddContact = () => {
if (newContact.trim() !== "" && !contactError) {
setContacts([...contacts, newContact.trim()])
resetContact()
}
}
const handleAddHostname = () => {
if (newHostname.trim() !== "" && !hostnameError) {
setHostnames([...hostnames, newHostname.trim()])
resetHostname()
}
}
const handleDeleteContact = (contact: string) => {
setContacts(contacts.filter(c => c !== contact))
}
const handleDeleteHostname = (hostname: string) => {
setHostnames(hostnames.filter(h => h !== hostname))
}
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!description || contacts.length === 0 || hostnames.length === 0) {
return
}
const newAccount = {
description,
contacts,
hostnames: hostnames.map(hostname => ({ hostname, expires: new Date(), isUpcomingExpire: false }))
}
// TODO: Implement API call to create new account
console.log("New account data:", newAccount)
// Reset form fields
setDescription("")
setContacts([])
setHostnames([])
}
return (
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold text-center mb-8">Register LetsEncrypt Account</h1>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<CustomInput
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Account Description"
title="Description"
inputClassName="border p-2 rounded w-full"
className="mb-4"
/>
</div>
<div className="mb-4">
<h3 className="text-xl font-medium mb-2">Contacts:</h3>
<ul className="list-disc list-inside pl-4 mb-2">
{contacts.map(contact => (
<li key={contact} className="text-gray-700 flex justify-between items-center mb-2">
{contact}
<button type="button" onClick={() => handleDeleteContact(contact)} className="bg-red-500 text-white px-2 py-1 rounded ml-4">
<FaTrash />
</button>
</li>
))}
</ul>
<div className="flex items-center mb-4">
<CustomInput
value={newContact}
onChange={handleContactChange}
placeholder="Add contact"
type="text"
error={contactError}
title="New Contact"
inputClassName="border p-2 rounded w-full"
errorClassName="text-red-500 text-sm mt-1"
className="mr-2 flex-grow"
/>
<button type="button" onClick={handleAddContact} className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center">
<FaPlus />
</button>
</div>
</div>
<div className="mb-4">
<h3 className="text-xl font-medium mb-2">Hostnames:</h3>
<ul className="list-disc list-inside pl-4 mb-2">
{hostnames.map(hostname => (
<li key={hostname} className="text-gray-700 flex justify-between items-center mb-2">
{hostname}
<button type="button" onClick={() => handleDeleteHostname(hostname)} className="bg-red-500 text-white px-2 py-1 rounded ml-4">
<FaTrash />
</button>
</li>
))}
</ul>
<div className="flex items-center">
<CustomInput
value={newHostname}
onChange={handleHostnameChange}
placeholder="Add hostname"
type="text"
error={hostnameError}
title="New Hostname"
inputClassName="border p-2 rounded w-full"
errorClassName="text-red-500 text-sm mt-1"
className="mr-2 flex-grow"
/>
<button type="button" onClick={handleAddHostname} className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center">
<FaPlus />
</button>
</div>
</div>
<CustomButton type="submit" className="bg-green-500 text-white px-3 py-1 rounded">
Create Account
</CustomButton>
</form>
</div>
)
}
export default RegisterPage

View File

@ -1,9 +1,16 @@
import React from 'react'
const Footer = () => {
interface FooterProps {
className?: string
}
const Footer = (props: FooterProps) => {
const { className } = props
return (
<footer className="bg-gray-900 text-white text-center p-4">
<p>&copy {new Date().getFullYear()} MAKS-IT</p>
<footer className={`bg-gray-900 text-white text-center p-4 ${className}`}>
<p>{`© ${new Date().getFullYear()} MAKS-IT`}</p>
</footer>
)
}

View File

@ -30,8 +30,7 @@ const Loader: React.FC = () => {
return (
<div className="loader-overlay">
<div className="spinner"></div>
<div className="loading-text">Loading...</div>
<span className="loader"></span>
</div>
)
}

View File

@ -12,23 +12,38 @@
flex-direction: column;
}
.spinner {
border: 8px solid rgba(255, 255, 255, 0.3);
border-top: 8px solid #3498db;
.loader {
width: 48px;
height: 48px;
display: inline-block;
position: relative;
}
.loader::after,
.loader::before {
content: '';
box-sizing: border-box;
width: 48px;
height: 48px;
border-radius: 50%;
width: 80px;
height: 80px;
animation: spin 1s linear infinite;
background: #FFF;
position: absolute;
left: 0;
top: 0;
animation: animloader 2s linear infinite;
}
.loader::after {
animation-delay: 1s;
}
.loading-text {
margin-top: 20px;
font-size: 1.2em;
color: #3498db;
@keyframes animloader {
0% {
transform: scale(0);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@ -1,41 +1,42 @@
import React, { FC, useEffect, useRef } from 'react'
import { FaHome, FaUser, FaCog, FaBars } from 'react-icons/fa'
import React, { FC } from 'react';
import { FaHome, FaUserPlus, FaBars, FaSyncAlt } from 'react-icons/fa';
import Link from 'next/link';
interface SideMenuProps {
isCollapsed: boolean
toggleSidebar: () => void
isCollapsed: boolean;
toggleSidebar: () => void;
}
const SideMenu: FC<SideMenuProps> = ({ isCollapsed, toggleSidebar }) => {
const menuItems = [
{ icon: <FaSyncAlt />, label: 'Auto Renew', path: '/' },
{ icon: <FaUserPlus />, label: 'Register', path: '/register' }
];
const SideMenu: FC<SideMenuProps> = ({ isCollapsed, toggleSidebar }) => {
return (
<div className={`flex flex-col bg-gray-800 text-white transition-all duration-300 ${isCollapsed ? 'w-16' : 'w-64'} h-full`}>
<div className="flex items-center h-16 bg-gray-900 relative">
<button onClick={toggleSidebar} className="absolute left-4">
<FaBars />
</button>
<h1 className={`${isCollapsed ? 'hidden' : 'block'} text-2xl font-bold ml-12`}>Logo</h1>
<h1 className={`${isCollapsed ? 'hidden' : 'block'} text-2xl font-bold ml-12`}>Certs UI</h1>
</div>
<nav className="flex-1">
<ul>
<li className="flex items-center p-4 hover:bg-gray-700">
<FaHome className="mr-4" />
<span className={`${isCollapsed ? 'hidden' : 'block'}`}>Home</span>
</li>
<li className="flex items-center p-4 hover:bg-gray-700">
<FaUser className="mr-4" />
<span className={`${isCollapsed ? 'hidden' : 'block'}`}>Profile</span>
</li>
<li className="flex items-center p-4 hover:bg-gray-700">
<FaCog className="mr-4" />
<span className={`${isCollapsed ? 'hidden' : 'block'}`}>Settings</span>
{menuItems.map((item, index) => (
<li key={index} className="hover:bg-gray-700">
<Link href={item.path} className="flex items-center w-full p-4">
<span className={`${isCollapsed ? 'mr-0' : 'mr-4'}`}>{item.icon}</span>
<span className={`${isCollapsed ? 'hidden' : 'block'}`}>{item.label}</span>
</Link>
</li>
))}
</ul>
</nav>
</div>
)
}
);
};
export {
SideMenu
}
};

View File

@ -4,7 +4,7 @@ import React from 'react'
interface CustomInputProps {
value: string
onChange: (value: string) => void
onChange?: (value: string) => void
placeholder?: string
type: 'text' | 'password' | 'email' | 'number' | 'tel' | 'url'
error?: string
@ -25,13 +25,18 @@ const CustomInput: React.FC<CustomInputProps> = ({
errorClassName = '',
className = ''
}) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e.target.value);
}
return (
<div className={className}>
{title && <label>{title}</label>}
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
onChange={handleChange}
placeholder={placeholder}
className={inputClassName}
/>

View File

@ -1,5 +1,5 @@
import { CustomButton } from "./customButton"
import { CustomInput } from "./customInput"
import { CustomButton } from "./customButton";
import { CustomInput } from "./customInput";
export {
CustomButton,

View File

@ -6,17 +6,35 @@ const isValidEmail = (email: string) => {
return emailRegex.test(email)
}
const isValidPhoneNumber = (phone: string) => {
const phoneRegex = /^\+?[1-9]\d{1,14}$/
return phoneRegex.test(phone)
}
const isValidContact = (contact: string) => {
return isValidEmail(contact) || isValidPhoneNumber(contact)
}
const isValidHostname = (hostname: string) => {
const hostnameRegex = /^(?!:\/\/)([a-zA-Z0-9-_]{1,63}\.?)+[a-zA-Z]{2,6}$/
return hostnameRegex.test(hostname)
}
// Props interface for useValidation hook
interface UseValidationProps {
initialValue: string
validateFn: (value: string) => boolean
errorMessage: string
}
// Custom hook for input validation
const useValidation = (initialValue: string, validateFn: (value: string) => boolean, errorMessage: string) => {
const useValidation = ({ initialValue, validateFn, errorMessage }: UseValidationProps) => {
const [value, setValue] = useState(initialValue)
const [error, setError] = useState("")
const handleChange = (newValue: string) => {
console.log(newValue)
setValue(newValue)
if (newValue.trim() === "") {
setError("This field cannot be empty.")
@ -31,7 +49,7 @@ const useValidation = (initialValue: string, validateFn: (value: string) => bool
handleChange(initialValue)
}, [initialValue])
return { value, error, handleChange }
return { value, error, handleChange, reset: () => setValue("") }
}
export { useValidation, isValidEmail, isValidHostname }
export { useValidation, isValidEmail, isValidPhoneNumber, isValidContact, isValidHostname }

View File

@ -1,3 +0,0 @@
export interface GetAccountsResponse {
accountIds: string[]
}

View File

@ -1,9 +0,0 @@
interface HostnameResponse {
hostname: string
expires: string,
isUpcomingExpire: boolean
}
export interface GetHostnamesResponse {
hostnames: HostnameResponse[]
}

View File

@ -0,0 +1,9 @@
import { HostnameResponse } from "./HostnameResponse";
export interface GetAccountResponse {
accountId: string,
contacts: string[],
hostnames: HostnameResponse[],
}

View File

@ -0,0 +1,5 @@
import { HostnameResponse } from "./HostnameResponse";
export interface GetHostnamesResponse {
hostnames: HostnameResponse[]
}

View File

@ -0,0 +1,5 @@
export interface HostnameResponse {
hostname: string
expires: string
isUpcomingExpire: boolean
}

View File

@ -1,55 +1,105 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
public class LockManager : IDisposable {
private readonly SemaphoreSlim _semaphore;
public LockManager(int initialCount, int maxCount) {
_semaphore = new SemaphoreSlim(initialCount, maxCount);
}
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
private readonly ConcurrentDictionary<int, int> _reentrantCounts = new ConcurrentDictionary<int, int>();
public async Task<T> ExecuteWithLockAsync<T>(Func<Task<T>> action) {
var threadId = Thread.CurrentThread.ManagedThreadId;
if (!_reentrantCounts.ContainsKey(threadId)) {
_reentrantCounts[threadId] = 0;
}
if (_reentrantCounts[threadId] == 0) {
await _semaphore.WaitAsync();
}
_reentrantCounts[threadId]++;
try {
return await action();
}
finally {
_reentrantCounts[threadId]--;
if (_reentrantCounts[threadId] == 0) {
_semaphore.Release();
}
}
}
public async Task ExecuteWithLockAsync(Func<Task> action) {
var threadId = Thread.CurrentThread.ManagedThreadId;
if (!_reentrantCounts.ContainsKey(threadId)) {
_reentrantCounts[threadId] = 0;
}
if (_reentrantCounts[threadId] == 0) {
await _semaphore.WaitAsync();
}
_reentrantCounts[threadId]++;
try {
await action();
}
finally {
_reentrantCounts[threadId]--;
if (_reentrantCounts[threadId] == 0) {
_semaphore.Release();
}
}
}
public async Task<T> ExecuteWithLockAsync<T>(Func<T> action) {
var threadId = Thread.CurrentThread.ManagedThreadId;
if (!_reentrantCounts.ContainsKey(threadId)) {
_reentrantCounts[threadId] = 0;
}
if (_reentrantCounts[threadId] == 0) {
await _semaphore.WaitAsync();
}
_reentrantCounts[threadId]++;
try {
return await Task.Run(action);
}
finally {
_reentrantCounts[threadId]--;
if (_reentrantCounts[threadId] == 0) {
_semaphore.Release();
}
}
}
public async Task ExecuteWithLockAsync(Action action) {
var threadId = Thread.CurrentThread.ManagedThreadId;
if (!_reentrantCounts.ContainsKey(threadId)) {
_reentrantCounts[threadId] = 0;
}
if (_reentrantCounts[threadId] == 0) {
await _semaphore.WaitAsync();
}
_reentrantCounts[threadId]++;
try {
await Task.Run(action);
}
finally {
_reentrantCounts[threadId]--;
if (_reentrantCounts[threadId] == 0) {
_semaphore.Release();
}
}
}
public void Dispose() {
_semaphore?.Dispose();
_semaphore.Dispose();
}
}

View File

@ -5,6 +5,8 @@ using DomainResults.Common;
using MaksIT.LetsEncryptServer.Services;
using Models.LetsEncryptServer.CertsFlow.Requests;
using Models.LetsEncryptServer.Cache.Responses;
using MaksIT.LetsEncrypt.Entities;
namespace MaksIT.LetsEncryptServer.BackgroundServices {
public class AutoRenewal : BackgroundService {
@ -30,26 +32,23 @@ namespace MaksIT.LetsEncryptServer.BackgroundServices {
while (!stoppingToken.IsCancellationRequested) {
_logger.LogInformation("Background service is running.");
var (accountsResponse, getAccountIdsResult) = await _cacheService.GetAccountsAsync();
var (accountsResponse, getAccountIdsResult) = await _cacheService.LoadAccountsFromCacheAsync();
if (!getAccountIdsResult.IsSuccess || accountsResponse == null) {
LogErrors(getAccountIdsResult.Errors);
continue;
}
foreach (var accountId in accountsResponse.AccountIds) {
await ProcessAccountAsync(accountId);
foreach (var account in accountsResponse) {
await ProcessAccountAsync(account);
}
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
private async Task<IDomainResult> ProcessAccountAsync(Guid accountId) {
var (cache, loadResult) = await _cacheService.LoadFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache == null) {
LogErrors(loadResult.Errors);
return loadResult;
}
private async Task<IDomainResult> ProcessAccountAsync(RegistrationCache cache) {
var hostnames = cache.GetHostsWithUpcomingSslExpiry();
if (hostnames == null) {
@ -63,11 +62,11 @@ namespace MaksIT.LetsEncryptServer.BackgroundServices {
return IDomainResult.Success();
}
var renewResult = await RenewCertificatesForHostnames(accountId, cache.Contacts, hostnames);
var renewResult = await RenewCertificatesForHostnames(cache.AccountId, cache.Contacts, hostnames);
if (!renewResult.IsSuccess)
return renewResult;
_logger.LogInformation($"Certificates renewed for account {accountId}");
_logger.LogInformation($"Certificates renewed for account {cache.AccountId}");
return IDomainResult.Success();
}

View File

@ -11,15 +11,12 @@ namespace MaksIT.LetsEncryptServer.Controllers;
[ApiController]
[Route("api/cache")]
public class CacheController : ControllerBase {
private readonly Configuration _appSettings;
private readonly ICacheRestService _cacheService;
public CacheController(
IOptions<Configuration> appSettings,
ICacheService cacheService
) {
_appSettings = appSettings.Value;
_cacheService = (ICacheRestService)cacheService;
_cacheService = cacheService;
}
[HttpGet("accounts")]

View File

@ -10,8 +10,9 @@ using Models.LetsEncryptServer.Cache.Responses;
namespace MaksIT.LetsEncryptServer.Services;
public interface ICacheService {
Task<(RegistrationCache?, IDomainResult)> LoadFromCacheAsync(Guid accountId);
public interface ICacheInternalsService {
Task<(RegistrationCache[]?, IDomainResult)> LoadAccountsFromCacheAsync();
Task<(RegistrationCache?, IDomainResult)> LoadAccountFromCacheAsync(Guid accountId);
Task<IDomainResult> SaveToCacheAsync(Guid accountId, RegistrationCache cache);
Task<IDomainResult> DeleteFromCacheAsync(Guid accountId);
}
@ -28,7 +29,9 @@ public interface ICacheRestService {
Task<(GetHostnamesResponse?, IDomainResult)> GetHostnames(Guid accountId);
}
public class CacheService : ICacheService, ICacheRestService, IDisposable {
public interface ICacheService : ICacheInternalsService, ICacheRestService {}
public class CacheService : ICacheService, IDisposable {
private readonly ILogger<CacheService> _logger;
private readonly string _cacheDirectory;
private readonly LockManager _lockManager;
@ -36,7 +39,7 @@ public class CacheService : ICacheService, ICacheRestService, IDisposable {
public CacheService(ILogger<CacheService> logger) {
_logger = logger;
_cacheDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "cache");
_lockManager = new LockManager(1, 1);
_lockManager = new LockManager();
if (!Directory.Exists(_cacheDirectory)) {
Directory.CreateDirectory(_cacheDirectory);
@ -50,9 +53,39 @@ public class CacheService : ICacheService, ICacheRestService, IDisposable {
return Path.Combine(_cacheDirectory, $"{accountId}.json");
}
private Guid[] GetCachedAccounts() {
return GetCacheFilesPaths().Select(x => Path.GetFileNameWithoutExtension(x).ToGuid()).Where(x => x != Guid.Empty).ToArray();
}
private string[] GetCacheFilesPaths() {
return Directory.GetFiles(_cacheDirectory);
}
#region Cache Operations
public Task<(RegistrationCache?, IDomainResult)> LoadFromCacheAsync(Guid accountId) {
public async Task<(RegistrationCache[]?, IDomainResult)> LoadAccountsFromCacheAsync() {
return await _lockManager.ExecuteWithLockAsync(async () => {
var accountIds = GetCachedAccounts();
var cacheLoadTasks = accountIds.Select(accountId => LoadFromCacheInternalAsync(accountId)).ToList();
var caches = new List<RegistrationCache>();
foreach (var task in cacheLoadTasks) {
var (registrationCache, getRegistrationCacheResult) = await task;
if (!getRegistrationCacheResult.IsSuccess || registrationCache == null) {
// Depending on how you want to handle partial failures, you might want to return here
// or continue loading other caches. For now, let's continue.
continue;
}
caches.Add(registrationCache);
}
return IDomainResult.Success(caches.ToArray());
});
}
public Task<(RegistrationCache?, IDomainResult)> LoadAccountFromCacheAsync(Guid accountId) {
return _lockManager.ExecuteWithLockAsync(() => LoadFromCacheInternalAsync(accountId));
}
@ -110,8 +143,8 @@ public class CacheService : ICacheService, ICacheRestService, IDisposable {
public async Task<(GetAccountResponse[]?, IDomainResult)> GetAccountsAsync() {
return await _lockManager.ExecuteWithLockAsync(async () => {
var cacheFiles = Directory.GetFiles(_cacheDirectory);
var accountIds = cacheFiles.Select(x => Path.GetFileNameWithoutExtension(x).ToGuid()).ToArray();
var accountIds = GetCachedAccounts();
var accounts = new List<GetAccountResponse>();
foreach (var accountId in accountIds) {
@ -128,7 +161,7 @@ public class CacheService : ICacheService, ICacheRestService, IDisposable {
public async Task<(GetAccountResponse?, IDomainResult)> GetAccountAsync(Guid accountId) {
return await _lockManager.ExecuteWithLockAsync(async () => {
var (cache, result) = await LoadFromCacheAsync(accountId);
var (cache, result) = await LoadAccountFromCacheAsync(accountId);
if (!result.IsSuccess || cache == null) {
return (null, result);
}
@ -145,7 +178,7 @@ public class CacheService : ICacheService, ICacheRestService, IDisposable {
}
public async Task<(GetAccountResponse?, IDomainResult)> PutAccountAsync(Guid accountId, PutAccountRequest requestData) {
var (cache, loadResult) = await LoadFromCacheAsync(accountId);
var (cache, loadResult) = await LoadAccountFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache == null) {
return (null, loadResult);
}
@ -162,7 +195,7 @@ public class CacheService : ICacheService, ICacheRestService, IDisposable {
}
public async Task<(GetAccountResponse?, IDomainResult)> PatchAccountAsync(Guid accountId, PatchAccountRequest requestData) {
var (cache, loadResult) = await LoadFromCacheAsync(accountId);
var (cache, loadResult) = await LoadAccountFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache == null) {
return (null, loadResult);
}
@ -209,7 +242,7 @@ public class CacheService : ICacheService, ICacheRestService, IDisposable {
#region Contacts Operations
public async Task<(GetContactsResponse?, IDomainResult)> GetContactsAsync(Guid accountId) {
var (cache, loadResult) = await LoadFromCacheAsync(accountId);
var (cache, loadResult) = await LoadAccountFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache == null) {
return (null, loadResult);
}
@ -220,7 +253,7 @@ public class CacheService : ICacheService, ICacheRestService, IDisposable {
}
public async Task<(GetAccountResponse?, IDomainResult)> PutContactsAsync(Guid accountId, PutContactsRequest requestData) {
var (cache, loadResult) = await LoadFromCacheAsync(accountId);
var (cache, loadResult) = await LoadAccountFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache == null) {
return (null, loadResult);
}
@ -235,7 +268,7 @@ public class CacheService : ICacheService, ICacheRestService, IDisposable {
}
public async Task<(GetAccountResponse?, IDomainResult)> PatchContactsAsync(Guid accountId, PatchContactsRequest requestData) {
var (cache, loadResult) = await LoadFromCacheAsync(accountId);
var (cache, loadResult) = await LoadAccountFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache == null) {
return (null, loadResult);
}
@ -274,7 +307,7 @@ public class CacheService : ICacheService, ICacheRestService, IDisposable {
}
public async Task<IDomainResult> DeleteContactAsync(Guid accountId, int index) {
var (cache, loadResult) = await LoadFromCacheAsync(accountId);
var (cache, loadResult) = await LoadAccountFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache == null) {
return loadResult;
}
@ -299,7 +332,7 @@ public class CacheService : ICacheService, ICacheRestService, IDisposable {
#region Hostnames Operations
public async Task<(GetHostnamesResponse?, IDomainResult)> GetHostnames(Guid accountId) {
var (cache, loadResult) = await LoadFromCacheAsync(accountId);
var (cache, loadResult) = await LoadAccountFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache?.CachedCerts == null) {
return (null, loadResult);
}

View File

@ -83,7 +83,7 @@ public class CertsFlowService : ICertsFlowService {
accountId = Guid.NewGuid();
}
else {
var (loadedCache, loadCaceResutl) = await _cacheService.LoadFromCacheAsync(accountId.Value);
var (loadedCache, loadCaceResutl) = await _cacheService.LoadAccountFromCacheAsync(accountId.Value);
if (!loadCaceResutl.IsSuccess || loadCaceResutl == null) {
accountId = Guid.NewGuid();
}