Initial commit
Build and Push Docker Images / build (push) Successful in 29s

This commit is contained in:
2026-05-02 21:31:31 +03:00
commit fc2dd721e4
78 changed files with 5002 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
name: Build and Push Docker Images
on:
push:
branches:
- main
env:
GIT_HOST: git.easysoft.ro
REGISTRY_HOST: registry.easysoft.ro
API_IMAGE: apps/myai-api
WEB_IMAGE: apps/myai-web
IMAGE_TAG: staging
jobs:
build:
runs-on: host
steps:
- name: Checkout repository
env:
TOKEN: ${{ secrets.REPO_TOKEN }}
run: |
git clone "http://gelu:${TOKEN}@${GIT_HOST}:3000/${GITHUB_REPOSITORY}.git" .
- name: Login to registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${REGISTRY_HOST}" \
-u "${{ secrets.REGISTRY_USER }}" \
--password-stdin
- name: Build API image
run: |
docker build -t "${REGISTRY_HOST}/${API_IMAGE}:${IMAGE_TAG}" ./api
- name: Build Web image
run: |
docker build -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" ./web
- name: Push API image
run: |
docker push "${REGISTRY_HOST}/${API_IMAGE}:${IMAGE_TAG}"
- name: Push Web image
run: |
docker push "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}"
+373
View File
@@ -0,0 +1,373 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# Environment Variables - DO NOT COMMIT
*.env
.env
.env.local
.env.*.local
api/.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# File storage directory
files/
+20
View File
@@ -0,0 +1,20 @@
# Introduction
TODO: Give a short introduction of your project. Let this section explain the objectives or the motivation behind this project.
# Getting Started
TODO: Guide users through getting your code up and running on their own system. In this section you can talk about:
1. Installation process
2. Software dependencies
3. Latest releases
4. API references
# Build and Test
TODO: Describe and show how to build your code and run the tests.
# Contribute
TODO: Explain how other users and developers can contribute to make your code better.
If you want to learn more about creating good readme files then refer the following [guidelines](https://docs.microsoft.com/en-us/azure/devops/repos/git/create-a-readme?view=azure-devops). You can also seek inspiration from the below readme files:
- [ASP.NET Core](https://github.com/aspnet/Home)
- [Visual Studio Code](https://github.com/Microsoft/vscode)
- [Chakra Core](https://github.com/Microsoft/ChakraCore)
+72
View File
@@ -0,0 +1,72 @@
# myai API - Environment Variables Template
# Copy this file to .env and fill in your values
# DO NOT commit .env to source control!
# ASP.NET Core Environment (Development, Staging, Production)
ASPNETCORE_ENVIRONMENT=Development
ASPNETCORE_URLS=http://+:8080
# Application Environment Name (shown in email subjects to identify which environment sent the email)
APP_ENVIRONMENT_NAME=myai.ro-Development
# Azure Key Vault (Optional - for production)
KeyVault__Enabled=false
KeyVault__VaultUri=https://your-keyvault-name.vault.azure.net/
# Note: If Key Vault is enabled, you can store secrets there instead of below
# The following settings can be overridden by Key Vault secrets
# SMTP Configuration
Smtp__Host=mail.example.com
Smtp__Port=587
Smtp__Username=no-reply@example.com
Smtp__Password=your-secure-password-here
Smtp__UseStartTls=true
# Google reCAPTCHA
Captcha__Provider=Recaptcha
Captcha__SecretKey=your-recaptcha-secret-key
Captcha__PublicKey=your-recaptcha-public-key
Captcha__MinimumScore=0.5
Captcha__ExpectedAction=
Captcha__ExpectedHostname=
# Google Services (optional - public keys safe to expose)
Google__TagManagerId=GTM-XXXXXXX
Google__MapKey=
# File Storage (relative to solution folder - defaults to "Files" if not set)
FileStorage__Path=Files
FileStorage__DefaultFileName=
FileStorage__ToEmail=admin@yourdomain.com
FileStorage__FromEmail=no-reply@yourdomain.com
FileStorage__SubjectPrefix=[File Download]
# Contact Settings
Contact__ToEmail=contact@yourdomain.com
Contact__FromEmail=no-reply@yourdomain.com
Contact__SubjectPrefix=[Contact]
# Subscribe Settings
Subscribe__ToEmail=contact@yourdomain.com
Subscribe__SubjectPrefix=[Subscribe]
# CORS - Allowed Origins (comma separated or multiple variables)
Cors__AllowedOrigins__0=https://yourdomain.com
Cors__AllowedOrigins__1=https://www.yourdomain.com
# Logging Configuration
Logging__LogLevel__Default=Information
Logging__LogLevel__Microsoft=Warning
Logging__LogLevel__Microsoft.AspNetCore=Warning
Logging__LogLevel__Api=Information
# Serilog Email Alerts (for Error notifications)
Serilog__WriteTo__2__Args__fromEmail=no-reply@yourdomain.com
Serilog__WriteTo__2__Args__toEmail=webmaster@yourdomain.com
Serilog__WriteTo__2__Args__mailServer=mail.example.com
Serilog__WriteTo__2__Args__networkCredential__userName=no-reply@yourdomain.com
Serilog__WriteTo__2__Args__networkCredential__password=your-password
Serilog__WriteTo__2__Args__port=587
Serilog__WriteTo__2__Args__enableSsl=true
+133
View File
@@ -0,0 +1,133 @@
using Api.Models;
using Api.Services.Contracts;
using Api.Settings;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
namespace Api.Controllers
{
/// <summary>
/// Exposes endpoints used by the frontend to send contact messages and to
/// subscribe to newsletters. All endpoints are protected by reCAPTCHA
/// verification and rate limiting.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[EnableCors("FrontendOnly")]
public sealed class ContactController : ControllerBase
{
private readonly CaptchaSettings _captchaSettings;
private readonly ICaptchaVerifier _captcha;
private readonly IEmailSender _email;
private readonly ILogger<ContactController> _log;
public ContactController(IOptions<CaptchaSettings> options, ICaptchaVerifier captcha, IEmailSender email, ILogger<ContactController> log)
{
_captchaSettings = options.Value;
_captcha = captcha;
_email = email;
_log = log;
}
/// <summary>
/// Returns the public reCAPTCHA site key used by the client to render
/// the reCAPTCHA widget and obtain client-side tokens.
/// </summary>
/// <returns>200 OK with the public site key as a string.</returns>
[HttpGet]
public async Task<IActionResult> GetReCaptchaSiteKey(CancellationToken ct)
{
return Ok(_captchaSettings.PublicKey);
}
/// <summary>
/// Validates the provided reCAPTCHA token and sends a contact message
/// via the configured email sender.
/// </summary>
/// <param name="req">Contact request containing name, email, subject,
/// and message. The <c>CaptchaToken</c> field is required for verification.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK when the message was queued/sent; 400 Bad Request when
/// captcha verification fails; 500 on internal errors.
/// </returns>
[HttpPost]
[EnableRateLimiting("contact")]
public async Task<IActionResult> Send([FromBody] ContactRequest req, CancellationToken ct)
{
if (!ModelState.IsValid)
return ValidationProblem(ModelState);
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
var res = await ValidateCaptcha(req.CaptchaToken, ct);
if (!res.Verdict.Success) return BadRequest("Captcha verification failed.");
try
{
await _email.SendContactAsync(req, ct);
return Ok(new { ok = true });
}
catch (Exception ex)
{
_log.LogError(ex, "Contact send failed. ip={Ip} from={From}", res.UserIp, req.Email);
return StatusCode(500, "Could not send message.");
}
}
/// <summary>
/// Validates the provided reCAPTCHA token and subscribes the given
/// email address to the newsletter or mailing list.
/// </summary>
/// <param name="req">Subscription request containing the email and
/// the <c>CaptchaToken</c>.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK when subscription succeeded; 400 when captcha verification
/// fails; 500 on internal errors.
/// </returns>
[HttpPost("subscribe")]
[EnableRateLimiting("contact")]
public async Task<IActionResult> Subscribe([FromBody] SubscribeRequest req, CancellationToken ct)
{
if (!ModelState.IsValid)
return ValidationProblem(ModelState);
var res = await ValidateCaptcha(req.CaptchaToken, ct);
if (!res.Verdict.Success) return BadRequest("Captcha verification failed.");
try
{
await _email.SendSubscribeAsync(req, ct);
return Ok(new { ok = true });
}
catch (Exception ex)
{
_log.LogError(ex, "Subscription failed. ip={Ip} eMail={eMail}", res.UserIp, req.Email);
return StatusCode(500, "Failed.");
}
}
/// <summary>
/// Helper that runs reCAPTCHA verification for the supplied token and
/// returns the verdict along with the resolved user IP address.
/// </summary>
/// <param name="token">Client-provided reCAPTCHA token.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Tuple containing the verification verdict and user IP.</returns>
private async Task<(CaptchaVerdict Verdict, string? UserIp)> ValidateCaptcha(string token, CancellationToken ct)
{
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
var verdict = await _captcha.VerifyAsync(token, userIp, ct);
if (!verdict.Success)
{
_log.LogWarning("Captcha failed. ip={Ip} score={Score} err={Err}",
userIp, verdict.Score, verdict.Error);
}
return (verdict, userIp);
}
}
}
+244
View File
@@ -0,0 +1,244 @@
using Api.Services.Contracts;
using Api.Settings;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
namespace Api.Controllers
{
/// <summary>
/// Controller for handling file downloads with support for resume and chunked transfers.
/// Routes are prefixed with "api/filedownload".
/// </summary>
[ApiController]
[Route("api/[controller]")]
[EnableCors("FrontendOnly")]
public sealed class FileDownloadController : ControllerBase
{
private readonly ILogger<FileDownloadController> _logger;
private readonly FileStorageSettings _fileStorageSettings;
private readonly IContentTypeProvider _contentTypeProvider;
private readonly IEmailSender _emailSender;
private const int BufferSize = 81920; // 80 KB buffer for optimal streaming performance
public FileDownloadController(
ILogger<FileDownloadController> logger,
IOptions<FileStorageSettings> fileStorageSettings,
IContentTypeProvider contentTypeProvider,
IEmailSender emailSender)
{
_logger = logger;
_fileStorageSettings = fileStorageSettings.Value;
_contentTypeProvider = contentTypeProvider;
_emailSender = emailSender;
}
/// <summary>
/// Downloads a file with support for resume (range requests) and chunked transfer.
/// Supports HTTP 206 Partial Content for efficient downloads and resume capability.
/// Sends email notification when download starts.
/// </summary>
/// <param name="fileName">The name of the file to download (optional - uses default from settings if not provided)</param>
/// <returns>File stream with appropriate headers for resumable downloads</returns>
/// <response code="200">Full file content</response>
/// <response code="206">Partial file content (range request)</response>
/// <response code="404">File not found</response>
/// <response code="416">Requested range not satisfiable</response>
[HttpGet("{fileName?}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status206PartialContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status416RangeNotSatisfiable)]
public async Task<IActionResult> DownloadFile(string? fileName = null)
{
try
{
// Use default file name from settings if not provided
if (string.IsNullOrWhiteSpace(fileName))
{
fileName = _fileStorageSettings.DefaultFileName;
if (string.IsNullOrWhiteSpace(fileName))
{
_logger.LogWarning("No file name provided and no default file name configured");
return BadRequest(new { error = "File name is required" });
}
_logger.LogInformation("Using default file name from settings: {FileName}", fileName);
}
// Get the file storage path (relative to solution folder)
var fileStoragePath = _fileStorageSettings.Path;
// If path is not absolute, make it relative to the solution root
if (!Path.IsPathRooted(fileStoragePath))
{
var solutionRoot = Directory.GetCurrentDirectory();
// Go up from api folder to solution root if needed
if (solutionRoot.EndsWith("api", StringComparison.OrdinalIgnoreCase))
{
solutionRoot = Directory.GetParent(solutionRoot)?.FullName ?? solutionRoot;
}
fileStoragePath = Path.Combine(solutionRoot, fileStoragePath);
}
// Sanitize fileName to prevent directory traversal attacks
var sanitizedFileName = Path.GetFileName(fileName);
var filePath = Path.Combine(fileStoragePath, sanitizedFileName);
// Verify file exists
if (!System.IO.File.Exists(filePath))
{
_logger.LogWarning("File not found: {FilePath}", filePath);
return NotFound(new { error = "File not found" });
}
var fileInfo = new FileInfo(filePath);
var fileLength = fileInfo.Length;
// Determine content type
if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType))
{
contentType = "application/octet-stream";
}
// Send email notification asynchronously (fire and forget with error handling)
// This is done before streaming to ensure notification is sent for both full and range downloads
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
_ = Task.Run(async () =>
{
try
{
await _emailSender.SendFileDownloadNotificationAsync(sanitizedFileName, userIp, CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send file download notification for {FileName}", sanitizedFileName);
}
});
// Check if this is a range request
var rangeHeader = Request.Headers[HeaderNames.Range].ToString();
if (!string.IsNullOrEmpty(rangeHeader))
{
return await HandleRangeRequest(filePath, fileLength, contentType, rangeHeader, sanitizedFileName);
}
// Full file download
_logger.LogInformation("Starting full file download: {FileName} ({FileSize} bytes)", sanitizedFileName, fileLength);
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true);
Response.Headers.Append(HeaderNames.AcceptRanges, "bytes");
Response.Headers.Append(HeaderNames.ContentLength, fileLength.ToString());
return File(stream, contentType, sanitizedFileName, enableRangeProcessing: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error downloading file: {FileName}", fileName);
return StatusCode(StatusCodes.Status500InternalServerError, new { error = "An error occurred while downloading the file" });
}
}
/// <summary>
/// Handles HTTP range requests for partial content downloads and resume support.
/// </summary>
private async Task<IActionResult> HandleRangeRequest(
string filePath,
long fileLength,
string contentType,
string rangeHeader,
string fileName)
{
try
{
// Parse range header (format: "bytes=start-end")
var range = rangeHeader.Replace("bytes=", "").Split('-');
long startByte = 0;
long endByte = fileLength - 1;
if (!string.IsNullOrEmpty(range[0]))
{
startByte = long.Parse(range[0]);
}
if (range.Length > 1 && !string.IsNullOrEmpty(range[1]))
{
endByte = long.Parse(range[1]);
}
// Validate range
if (startByte > endByte || startByte >= fileLength)
{
_logger.LogWarning("Invalid range request: {Range} for file size {FileLength}", rangeHeader, fileLength);
return StatusCode(StatusCodes.Status416RangeNotSatisfiable);
}
// Adjust end byte if it exceeds file length
if (endByte >= fileLength)
{
endByte = fileLength - 1;
}
var contentLength = endByte - startByte + 1;
_logger.LogInformation(
"Range request for {FileName}: bytes {Start}-{End}/{Total} ({ContentLength} bytes)",
fileName, startByte, endByte, fileLength, contentLength);
// Open file stream and seek to start position
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true);
stream.Seek(startByte, SeekOrigin.Begin);
// Set response headers for partial content
Response.StatusCode = StatusCodes.Status206PartialContent;
Response.Headers.Append(HeaderNames.AcceptRanges, "bytes");
Response.Headers.Append(HeaderNames.ContentRange, $"bytes {startByte}-{endByte}/{fileLength}");
Response.Headers.Append(HeaderNames.ContentLength, contentLength.ToString());
Response.ContentType = contentType;
// Stream the requested range
await StreamRangeAsync(stream, Response.Body, contentLength);
await stream.DisposeAsync();
return new EmptyResult();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing range request for file: {FileName}", fileName);
return StatusCode(StatusCodes.Status500InternalServerError);
}
}
/// <summary>
/// Efficiently streams a specific byte range from source to destination.
/// </summary>
private static async Task StreamRangeAsync(Stream source, Stream destination, long bytesToRead)
{
var buffer = new byte[BufferSize];
long totalBytesRead = 0;
while (totalBytesRead < bytesToRead)
{
var bytesToReadThisIteration = (int)Math.Min(BufferSize, bytesToRead - totalBytesRead);
var bytesRead = await source.ReadAsync(buffer.AsMemory(0, bytesToReadThisIteration));
if (bytesRead == 0)
{
break; // End of stream
}
await destination.WriteAsync(buffer.AsMemory(0, bytesRead));
totalBytesRead += bytesRead;
}
await destination.FlushAsync();
}
}
}
+50
View File
@@ -0,0 +1,50 @@
using Api.Settings;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace Api.Controllers
{
/// <summary>
/// Provides simple endpoints to expose Google related public keys used by
/// the frontend (for example Google Analytics tag id and Maps API key).
/// These endpoints return only public values safe to be exposed to clients.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[EnableCors("FrontendOnly")]
public sealed class GoogleController : ControllerBase
{
private readonly GoogleSettings _googleSettings;
private readonly ILogger<GoogleController> _log;
public GoogleController(IOptions<GoogleSettings> options, ILogger<GoogleController> log)
{
_googleSettings = options.Value;
_log = log;
}
/// <summary>
/// Returns the Google Tag Manager ID used by the frontend for analytics and tracking.
/// </summary>
/// <returns>200 OK with the Tag Manager ID as a string.</returns>
[HttpGet("tagmanager")]
public async Task<IActionResult> GetTagManagerId(CancellationToken ct)
{
return Ok(_googleSettings.TagManagerId);
}
/// <summary>
/// Returns the Google Maps API key used by the frontend to load the
/// Maps JavaScript API. This key is expected to be restricted and
/// safe to expose for client-side usage.
/// </summary>
/// <returns>200 OK with the maps API key as a string.</returns>
[HttpGet("maps")]
public async Task<IActionResult> GetMapKey(CancellationToken ct)
{
return Ok(_googleSettings.MapKey);
}
}
}
+67
View File
@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
namespace Api.Controllers
{
/// <summary>
/// Controller that exposes simple health and readiness endpoints for the API.
/// Routes are prefixed with "api/health".
/// </summary>
[ApiController]
[Route("api/[controller]")]
// Enables only the "FrontendOnly" CORS policy so browser requests from the frontend are allowed.
[EnableCors("FrontendOnly")]
public sealed class HealthController : ControllerBase
{
/// <summary>
/// Liveness probe.
/// Indicates whether the process is running. Used by orchestration systems to confirm the process is alive.
/// </summary>
/// <returns>
/// 200 OK with JSON payload: { "status": "alive" } when the process is running.
/// </returns>
// GET api/health/live
[HttpGet("live")]
public IActionResult Live() => Ok(new { status = "alive" });
/// <summary>
/// Basic health check endpoint.
/// Returns overall status and the current server time in UTC.
/// </summary>
/// <returns>
/// 200 OK with JSON payload: { "status": "ok", "time": &lt;UTC time&gt; }.
/// </returns>
// GET api/health
[HttpGet]
public IActionResult Health() => Ok(new { status = "ok", time = DateTimeOffset.UtcNow });
/// <summary>
/// Echo endpoint.
/// Returns the received JSON payload unchanged. Useful for testing request/response plumbing.
/// </summary>
/// <param name="payload">Arbitrary JSON from the request body. The endpoint returns the same object.</param>
/// <returns>200 OK with the same JSON payload provided in the request body.</returns>
// POST api/health/echo
[HttpPost("echo")]
public IActionResult Echo(object payload) => Ok(payload);
/// <summary>
/// Readiness probe.
/// Indicates whether the service is ready to accept traffic. Typically checks downstream dependencies.
/// </summary>
/// <returns>
/// 200 OK with JSON { "status": "ready" } when ready;
/// 503 Service Unavailable with JSON { "status": "not_ready" } when not ready.
/// </returns>
// GET api/health/ready
[HttpGet("ready")]
public IActionResult Ready()
{
var ready = true;
return ready
? Ok(new { status = "ready" })
: StatusCode(503, new { status = "not_ready" });
}
}
}
+19
View File
@@ -0,0 +1,19 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src/api
# Copy the project file and restore first to leverage Docker layer caching
COPY api.csproj ./
RUN dotnet restore api.csproj
# Copy only the api project files to avoid bringing other projects into the build context
COPY . ./
RUN dotnet publish api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
EXPOSE 8080
ENV ASPNETCORE_URLS=http://0.0.0.0:8080
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "api.dll"]
+23
View File
@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
namespace Api.Models
{
public sealed class ContactRequest
{
[Required, StringLength(100)]
public string Name { get; set; } = "";
[Required, EmailAddress, StringLength(200)]
public string Email { get; set; } = "";
[Required, StringLength(200)]
public string Subject { get; set; } = "";
[Required, StringLength(5000)]
public string Message { get; set; } = "";
// Token returned by the captcha widget
[Required]
public string CaptchaToken { get; set; } = "";
}
}
+15
View File
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace Api.Models
{
public sealed class SubscribeRequest
{
[Required, EmailAddress, StringLength(200)]
public string Email { get; set; } = "";
// Token returned by the captcha widget
[Required]
public string CaptchaToken { get; set; } = "";
}
}
+368
View File
@@ -0,0 +1,368 @@
using Api.Services;
using Api.Services.Contracts;
using Api.Settings;
using Azure.Identity;
using Microsoft.AspNetCore.HttpOverrides;
using Serilog;
using System.Reflection;
using System.Threading.RateLimiting;
// Load .env file if it exists (for local development)
DotNetEnv.Env.Load();
try
{
var builder = WebApplication.CreateBuilder(args);
var appVersion =
Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion
?? Assembly.GetExecutingAssembly().GetName().Version?.ToString()
?? "unknown";
builder.Host.UseSerilog((context, services, configuration) =>
{
configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithProperty("AppVersion", appVersion)
.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter());
});
Log.Information("Starting API version {AppVersion}", appVersion);
// --------------------
// Azure Key Vault Configuration
// --------------------
var keyVaultUri = builder.Configuration["KeyVault:VaultUri"];
var keyVaultEnabled = builder.Configuration.GetValue<bool>("KeyVault:Enabled");
if (keyVaultEnabled && !string.IsNullOrWhiteSpace(keyVaultUri))
{
Log.Information("Loading configuration from Azure Key Vault: {VaultUri}", keyVaultUri);
try
{
builder.Configuration.AddAzureKeyVault(
new Uri(keyVaultUri),
new DefaultAzureCredential());
Log.Information("Azure Key Vault configuration loaded successfully");
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to load Azure Key Vault configuration. Continuing with other configuration sources.");
}
}
else
{
Log.Information("Azure Key Vault is disabled or not configured");
}
// Controllers
builder.Services.AddControllers();
// Options
builder.Services.Configure<GoogleSettings>(builder.Configuration.GetSection("Google"));
builder.Services.Configure<ContactSettings>(builder.Configuration.GetSection("Contact"));
builder.Services.Configure<SubscribeSettings>(builder.Configuration.GetSection("Subscribe"));
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
builder.Services.Configure<CaptchaSettings>(builder.Configuration.GetSection("Captcha"));
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
// Services
builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>();
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
// Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// If you're behind Caddy / reverse proxy
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// use the normalized header Caddy sends upstream.
options.ForwardedForHeaderName = "X-Real-IP";
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
options.ForwardLimit = 1;
});
// --------------------
// CORS (lock it down)
// --------------------
// Configure allowed origins via config/env var.
// Example env var in Docker: Cors__AllowedOrigins__0=https://app.yourdomain.com
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
builder.Services.AddCors(options =>
{
options.AddPolicy("FrontendOnly", policy =>
{
// If none configured, fail closed: allow nothing.
if (allowedOrigins.Length > 0)
{
policy.WithOrigins(allowedOrigins)
.WithMethods("POST", "OPTIONS") // contact form only
.WithHeaders("Content-Type") // keep minimal
.SetPreflightMaxAge(TimeSpan.FromHours(1));
}
});
});
// --------------------
// Rate Limiting
// --------------------
// Two layers:
// 1) A global limiter (keeps random traffic sane).
// 2) A stricter policy for /api/contact.
builder.Services.AddRateLimiter(options =>
{
// Global: per IP, moderate
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: ip,
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 120, // 120 req
Window = TimeSpan.FromMinutes(1), // per minute
QueueLimit = 0,
AutoReplenishment = true
}
);
});
// Policy: contact endpoint, stricter (per IP)
options.AddPolicy("contact", httpContext =>
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: ip,
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5, // 5 submits
Window = TimeSpan.FromMinutes(1), // per minute per IP
QueueLimit = 0,
AutoReplenishment = true
}
);
});
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, ct) =>
{
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILogger<Program>>();
var ip = context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var endpoint = context.HttpContext.Request.Path;
logger.LogWarning(
"Rate limit exceeded for {Endpoint} from IP {IP}",
endpoint, ip
);
// Small, bot-unfriendly response
context.HttpContext.Response.ContentType = "application/json";
await context.HttpContext.Response.WriteAsync(
"""{"error":"Too many requests. Try again later."}""",
ct
);
};
});
var app = builder.Build();
var logger = app.Services.GetRequiredService<ILogger<Program>>();
logger.LogInformation("API starting up...");
logger.LogInformation("Environment: {Environment}", app.Environment.EnvironmentName);
// Log all environment variables and configuration settings at startup
// Can be controlled via appsettings: "Logging:LogEnvironmentOnStartup": true
var logEnvironmentOnStartup = app.Configuration.GetValue<bool>("Logging:LogEnvironmentOnStartup", defaultValue: true);
if (logEnvironmentOnStartup)
{
LogEnvironmentSettings(logger, app.Configuration, app.Environment);
}
// Forwarded headers must be early in the pipeline
app.UseForwardedHeaders();
// Add Serilog request logging
app.UseSerilogRequestLogging(options =>
{
options.MessageTemplate =
"HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
diagnosticContext.Set("RemoteIP", httpContext.Connection.RemoteIpAddress?.ToString());
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString());
diagnosticContext.Set("XRealIP", httpContext.Request.Headers["X-Real-IP"].ToString());
diagnosticContext.Set("XForwardedFor", httpContext.Request.Headers["X-Forwarded-For"].ToString());
};
});
// Swagger (typically only in Development)
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.DocumentTitle = "API";
options.SwaggerEndpoint("/swagger/v1/swagger.json", "API v1");
options.RoutePrefix = "swagger"; // /swagger
});
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.UseRouting();
app.UseCors("FrontendOnly");
app.UseRateLimiter();
app.MapControllers();
logger.LogInformation("API startup complete. Listening for requests...");
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.Information("Shutting down API...");
Log.CloseAndFlush();
}
/// <summary>
/// Logs all environment variables and configuration settings at startup for diagnostics.
/// </summary>
static void LogEnvironmentSettings(Microsoft.Extensions.Logging.ILogger logger, IConfiguration configuration, IWebHostEnvironment environment)
{
logger.LogInformation("==================== ENVIRONMENT SETTINGS ====================");
// Environment Information
logger.LogInformation("Application Name: {ApplicationName}", environment.ApplicationName);
logger.LogInformation("Environment Name: {EnvironmentName}", environment.EnvironmentName);
logger.LogInformation("Content Root Path: {ContentRootPath}", environment.ContentRootPath);
logger.LogInformation("Web Root Path: {WebRootPath}", environment.WebRootPath);
// Environment Variables
logger.LogInformation("-------------- Environment Variables --------------");
var envVars = Environment.GetEnvironmentVariables();
var sortedEnvVars = new SortedDictionary<string, string?>();
foreach (System.Collections.DictionaryEntry entry in envVars)
{
var key = entry.Key?.ToString() ?? string.Empty;
var value = entry.Value?.ToString() ?? string.Empty;
// Mask sensitive values (passwords, secrets, tokens, keys) but show last 4 characters
if (IsSensitiveKey(key))
{
value = MaskValueWithLastChars(value);
}
sortedEnvVars[key] = value;
}
foreach (var kvp in sortedEnvVars)
{
logger.LogInformation(" {Key} = {Value}", kvp.Key, kvp.Value);
}
// Configuration Settings
logger.LogInformation("-------------- Configuration Settings --------------");
LogConfigurationRecursive(logger, configuration.GetChildren(), "");
logger.LogInformation("===========================================================");
}
/// <summary>
/// Recursively logs configuration settings with hierarchy.
/// </summary>
static void LogConfigurationRecursive(Microsoft.Extensions.Logging.ILogger logger, IEnumerable<IConfigurationSection> sections, string prefix)
{
foreach (var section in sections)
{
var key = string.IsNullOrEmpty(prefix) ? section.Key : $"{prefix}:{section.Key}";
if (section.Value != null)
{
var value = section.Value;
// Mask sensitive configuration values but show last 4 characters
if (IsSensitiveKey(key))
{
value = MaskValueWithLastChars(value);
}
logger.LogInformation(" {Key} = {Value}", key, value);
}
// Recurse into child sections
if (section.GetChildren().Any())
{
LogConfigurationRecursive(logger, section.GetChildren(), key);
}
}
}
/// <summary>
/// Checks if a configuration key contains sensitive information.
/// </summary>
static bool IsSensitiveKey(string key)
{
return key.Contains("Password", StringComparison.OrdinalIgnoreCase) ||
key.Contains("Secret", StringComparison.OrdinalIgnoreCase) ||
key.Contains("Token", StringComparison.OrdinalIgnoreCase) ||
key.Contains("Key", StringComparison.OrdinalIgnoreCase) ||
key.Contains("ConnectionString", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Masks a sensitive value but shows the last 4 characters for verification.
/// </summary>
/// <param name="value">The value to mask.</param>
/// <returns>Masked value showing last 4 characters (e.g., "***MASKED***...abcd")</returns>
static string MaskValueWithLastChars(string value)
{
if (string.IsNullOrEmpty(value))
{
return "***NOT SET***";
}
// If value is too short, just mask it completely
if (value.Length <= 4)
{
return "***MASKED***";
}
// Show last 4 characters
var lastChars = value.Substring(value.Length - 4);
return $"***MASKED***...{lastChars}";
}
+38
View File
@@ -0,0 +1,38 @@
{
"profiles": {
"api": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:55119;http://localhost:55121"
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Container (Dockerfile)": {
"commandName": "Docker",
"launchBrowser": true,
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
"environmentVariables": {
"ASPNETCORE_HTTPS_PORTS": "8081",
"ASPNETCORE_HTTP_PORTS": "8080"
},
"publishAllPorts": true,
"useSSL": true
}
},
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:62186/",
"sslPort": 44392
}
}
}
@@ -0,0 +1,9 @@
namespace Api.Services.Contracts
{
public sealed record CaptchaVerdict(bool Success, string? Error, double? Score);
public interface ICaptchaVerifier
{
Task<CaptchaVerdict> VerifyAsync(string token, string? userIp, CancellationToken ct);
}
}
+11
View File
@@ -0,0 +1,11 @@
using Api.Models;
namespace Api.Services.Contracts
{
public interface IEmailSender
{
Task SendContactAsync(ContactRequest req, CancellationToken ct);
Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct);
Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct);
}
}
+104
View File
@@ -0,0 +1,104 @@
using Api.Services.Contracts;
using Api.Settings;
using Microsoft.Extensions.Options;
namespace Api.Services
{
public sealed class RecaptchaVerifier : ICaptchaVerifier
{
private readonly HttpClient _http;
private readonly CaptchaSettings _opt;
private readonly ILogger<RecaptchaVerifier> _log;
public RecaptchaVerifier(HttpClient http, IOptions<CaptchaSettings> options, ILogger<RecaptchaVerifier> log)
{
_http = http;
_opt = options.Value;
_log = log;
}
public async Task<CaptchaVerdict> VerifyAsync(string token, string? userIp, CancellationToken ct)
{
_log.LogDebug("Verifying captcha token for IP {Ip}", userIp ?? "unknown");
if (string.IsNullOrWhiteSpace(_opt.SecretKey))
{
_log.LogWarning("Captcha verification attempted but SecretKey is not configured");
return new CaptchaVerdict(false, "Captcha not configured", null);
}
var form = new Dictionary<string, string>
{
["secret"] = _opt.SecretKey,
["response"] = token
};
if (!string.IsNullOrWhiteSpace(userIp))
form["remoteip"] = userIp;
using var resp = await _http.PostAsync(
"https://www.google.com/recaptcha/api/siteverify",
new FormUrlEncodedContent(form),
ct
);
if (!resp.IsSuccessStatusCode)
{
_log.LogWarning("Captcha HTTP request failed with status {StatusCode} for IP {Ip}",
(int)resp.StatusCode, userIp ?? "unknown");
return new CaptchaVerdict(false, $"Captcha HTTP {(int)resp.StatusCode}", null);
}
var data = await resp.Content.ReadFromJsonAsync<RecaptchaResponse>(cancellationToken: ct);
if (data is null)
{
_log.LogError("Failed to parse captcha response for IP {Ip}", userIp ?? "unknown");
return new CaptchaVerdict(false, "Captcha parse error", null);
}
if (!data.success)
{
_log.LogWarning("Captcha verification failed for IP {Ip}. Score={Score}",
userIp ?? "unknown", data.score);
return new CaptchaVerdict(false, "Captcha failed", data.score);
}
// v3 score check (score is typically null for v2)
if (data.score is double score && score < _opt.MinimumScore)
{
_log.LogWarning("Captcha score {Score} below minimum {MinScore} for IP {Ip}",
score, _opt.MinimumScore, userIp ?? "unknown");
return new CaptchaVerdict(false, "Captcha score too low", score);
}
// Optional strictness (usually v3): action/hostname checks
if (!string.IsNullOrWhiteSpace(_opt.ExpectedAction) &&
!string.Equals(_opt.ExpectedAction, data.action, StringComparison.Ordinal))
{
_log.LogWarning("Captcha action mismatch. Expected={Expected}, Actual={Actual}, IP={Ip}",
_opt.ExpectedAction, data.action, userIp ?? "unknown");
return new CaptchaVerdict(false, "Captcha action mismatch", data.score);
}
if (!string.IsNullOrWhiteSpace(_opt.ExpectedHostname) &&
!string.Equals(_opt.ExpectedHostname, data.hostname, StringComparison.OrdinalIgnoreCase))
{
_log.LogWarning("Captcha hostname mismatch. Expected={Expected}, Actual={Actual}, IP={Ip}",
_opt.ExpectedHostname, data.hostname, userIp ?? "unknown");
return new CaptchaVerdict(false, "Captcha hostname mismatch", data.score);
}
_log.LogInformation("Captcha verified successfully for IP {Ip}. Score={Score}",
userIp ?? "unknown", data.score);
return new CaptchaVerdict(true, null, data.score);
}
private sealed class RecaptchaResponse
{
public bool success { get; set; }
public double? score { get; set; } // v3
public string? action { get; set; } // v3
public string? hostname { get; set; }
public DateTimeOffset? challenge_ts { get; set; }
}
}
}
+170
View File
@@ -0,0 +1,170 @@
using Api.Services.Contracts;
using Api.Models;
using Microsoft.Extensions.Options;
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
using Api.Settings;
namespace Api.Services
{
public sealed class SmtpEmailSender : IEmailSender
{
private readonly SmtpSettings _smtp;
private readonly ContactSettings _contact;
private readonly SubscribeSettings _subscribe;
private readonly FileStorageSettings _fileStorage;
private readonly ILogger<SmtpEmailSender> _log;
private readonly string _environmentName;
public SmtpEmailSender(IOptions<SmtpSettings> smtp,
IOptions<ContactSettings> contact,
IOptions<SubscribeSettings> subscribe,
IOptions<FileStorageSettings> fileStorage,
ILogger<SmtpEmailSender> log)
{
_smtp = smtp.Value;
_contact = contact.Value;
_subscribe = subscribe.Value;
_fileStorage = fileStorage.Value;
_log = log;
// Use APP_ENVIRONMENT_NAME from environment variable (set in docker-compose) with fallback to "Development"
_environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
}
public async Task SendContactAsync(ContactRequest req, CancellationToken ct)
{
// Throw error if ToEmail is not configured, since contact requests are important to process.
if (string.IsNullOrWhiteSpace(_contact.ToEmail))
{
_log.LogDebug("Contact email skipped - ToEmail not configured");
throw new InvalidOperationException("Contact email recipient is not configured.");
}
_log.LogInformation("Preparing contact email from {SenderEmail} to {RecipientEmail}",
req.Email, _contact.ToEmail);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_contact.ToEmail));
msg.ReplyTo.Add(MailboxAddress.Parse(req.Email));
msg.Subject = $"{_contact.SubjectPrefix} [{_environmentName}] {req.Subject}".Trim();
var body =
$@"New contact form submission:
Name: {req.Name}
Email: {req.Email}
Subject: {req.Subject}
Message:
{req.Message}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "contact email", ct);
_log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email);
}
public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct)
{
// Throw error if ToEmail is not configured, since subscription requests are important to process.
if (string.IsNullOrWhiteSpace(_subscribe.ToEmail))
{
_log.LogDebug("Subscription email skipped - ToEmail not configured");
throw new InvalidOperationException("Subscription email recipient is not configured.");
}
_log.LogInformation("Processing subscription request for {Email}", req.Email);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_subscribe.ToEmail));
msg.ReplyTo.Add(MailboxAddress.Parse(req.Email));
msg.Subject = $"{_subscribe.SubjectPrefix} [{_environmentName}]".Trim();
var body =
$@"New subscription request:
Email: {req.Email}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "subscription email", ct);
_log.LogInformation("Subscription email sent successfully for {Email}", req.Email);
}
public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct)
{
// Skip sending if ToEmail is not configured
if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail))
{
_log.LogDebug("File download notification skipped - ToEmail not configured");
return;
}
_log.LogInformation("Preparing file download notification for {FileName}", fileName);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_fileStorage.ToEmail));
msg.Subject = $"{_fileStorage.SubjectPrefix} [{_environmentName}] {fileName}".Trim();
var body =
$@"File download notification:
File: {fileName}
Downloaded at: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC
IP Address: {userIp ?? "Unknown"}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "file download notification email", ct);
_log.LogInformation("File download notification sent successfully for {FileName}", fileName);
}
/// <summary>
/// Connects to the SMTP server and authenticates if credentials are configured.
/// </summary>
private async Task ConnectAndAuthenticateAsync(SmtpClient client, CancellationToken ct)
{
// If you're in enterprise environments, you may need to tweak certificate validation.
// Don't disable it casually.
var tls = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
_log.LogDebug("Connecting to SMTP server {Host}:{Port} with security={Security}",
_smtp.Host, _smtp.Port, tls);
await client.ConnectAsync(_smtp.Host, _smtp.Port, tls, ct);
if (!string.IsNullOrWhiteSpace(_smtp.Username))
{
_log.LogDebug("Authenticating with SMTP server as {Username}", _smtp.Username);
await client.AuthenticateAsync(_smtp.Username, _smtp.Password, ct);
}
}
/// <summary>
/// Sends an email message using SMTP.
/// </summary>
/// <param name="message">The email message to send.</param>
/// <param name="messageType">Description of the message type for logging purposes.</param>
/// <param name="ct">Cancellation token.</param>
private async Task SendEmailAsync(MimeMessage message, string messageType, CancellationToken ct)
{
using var client = new SmtpClient();
await ConnectAndAuthenticateAsync(client, ct);
_log.LogDebug("Sending {MessageType} message", messageType);
await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct);
}
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace Api.Settings
{
public sealed class CaptchaSettings
{
// "Recaptcha" for now (easy to extend later)
public string Provider { get; set; } = "Recaptcha";
public string SecretKey { get; set; } = "";
public string PublicKey { get; set; } = "";
// Only relevant for reCAPTCHA v3 (score-based)
public double MinimumScore { get; set; } = 0.5;
// Optional but recommended for v3: enforce expected action and/or hostname
public string? ExpectedAction { get; set; }
public string? ExpectedHostname { get; set; }
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Api.Settings
{
public sealed class ContactSettings
{
public string ToEmail { get; set; } = "";
public string SubjectPrefix { get; set; } = "[Contact]";
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace Api.Settings
{
public sealed class FileStorageSettings
{
public string Path { get; set; } = "Files";
public string DefaultFileName { get; set; } = "";
public string ToEmail { get; set; } = "";
public string SubjectPrefix { get; set; } = "[File Download]";
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Api.Settings
{
public sealed class GoogleSettings
{
public string TagManagerId { get; set; } = "";
public string MapKey { get; set; } = "";
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Api.Settings
{
public sealed class KeyVaultSettings
{
public string VaultUri { get; set; } = "";
public bool Enabled { get; set; } = false;
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Api.Settings
{
public class SmtpSettings
{
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public bool UseStartTls { get; set; } = true;
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Api.Settings
{
public sealed class SubscribeSettings
{
public string ToEmail { get; set; } = "";
public string SubjectPrefix { get; set; } = "[Subscribe]";
}
}
+32
View File
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0-build.$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss"))</Version>
<InformationalVersion>$(Version)</InformationalVersion>
<!-- Good defaults for reverse-proxy scenarios -->
<InvariantGlobalization>false</InvariantGlobalization>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DisableStaticWebAssets>true</DisableStaticWebAssets>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.1" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="DotNetEnv" Version="3.2.0" />
<PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Email" Version="4.2.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<ItemGroup>
<Folder Include="logs\" />
</ItemGroup>
</Project>
+79
View File
@@ -0,0 +1,79 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft.AspNetCore": "Information",
"Microsoft.AspNetCore.Hosting": "Information",
"Microsoft.AspNetCore.Routing": "Warning",
"System.Net.Http.HttpClient": "Warning",
"Api": "Debug"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "logs/dev-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 7,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
}
}
]
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Information",
"Microsoft.AspNetCore.Hosting": "Information",
"Microsoft.AspNetCore.Routing": "Warning",
"System.Net.Http.HttpClient": "Warning",
"Api": "Debug"
},
"LogEnvironmentOnStartup": true
},
"KeyVault": {
"VaultUri": "",
"Enabled": false
},
"Google": {
"TagManagerId": "",
"MapKey": ""
},
"Contact": {
"ToEmail": "",
"FromEmail": "",
"SubjectPrefix": ""
},
"Subscribe": {
"ToEmail": "",
"SubjectPrefix": ""
},
"Smtp": {
"Host": "mail.example.com",
"Port": 587,
"Username": "",
"Password": "",
"UseStartTls": false
},
"Captcha": {
"Provider": "Recaptcha",
"SecretKey": "",
"PublicKey": "",
"MinimumScore": 0.5
},
"FileStorage": {
"Path": "Files",
"DefaultFileName": "",
"ToEmail": "",
"FromEmail": "",
"SubjectPrefix": "[File Download]"
}
}
+102
View File
@@ -0,0 +1,102 @@
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File", "Serilog.Sinks.Email" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.Hosting": "Information",
"Microsoft.AspNetCore.Routing": "Warning",
"System.Net.Http.HttpClient": "Warning",
"Api": "Information"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "logs/api-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 30,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "Email",
"Args": {
"restrictedToMinimumLevel": "Error",
"fromEmail": "",
"toEmail": "",
"mailServer": "",
"networkCredential": {
"userName": "",
"password": ""
},
"port": 587,
"enableSsl": true,
"emailSubject": "[mihes.ro API] Error Alert",
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
"batchPostingLimit": 10,
"period": "0.00:05:00"
}
}
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithEnvironmentName" ]
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.Hosting": "Information",
"Microsoft.AspNetCore.Routing": "Warning",
"System.Net.Http.HttpClient": "Warning",
"Api": "Information"
},
"LogEnvironmentOnStartup": true
},
"AllowedHosts": "*",
"KeyVault": {
"VaultUri": "",
"Enabled": false
},
"Google": {
"TagManagerId": "",
"MapKey": ""
},
"Contact": {
"ToEmail": "",
"FromEmail": "",
"SubjectPrefix": ""
},
"Subscribe": {
"ToEmail": "",
"SubjectPrefix": ""
},
"Smtp": {
"Host": "mail.example.com",
"Port": 587,
"Username": "",
"Password": "",
"UseStartTls": false
},
"Captcha": {
"Provider": "Recaptcha",
"SecretKey": "",
"PublicKey": "",
"MinimumScore": 0.5
},
"FileStorage": {
"Path": "Files",
"DefaultFileName": "",
"ToEmail": "",
"FromEmail": "",
"SubjectPrefix": "[File Download]"
}
}
+9
View File
@@ -0,0 +1,9 @@
**/bin/
**/obj/
**/.vs/
**/.git/
**/.gitignore
**/*.user
**/*.suo
**/*.cache
**/node_modules/
+24
View File
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.Docker.Sdk">
<PropertyGroup Label="Globals">
<ProjectGuid>81dded9d-158b-e303-5f62-77a2896d2a5a</ProjectGuid>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<DockerComposeFiles>docker-compose.yml;docker-compose.override.yml</DockerComposeFiles>
<DockerTargetOS>Linux</DockerTargetOS>
<ProjectVersion>2.1</ProjectVersion>
</PropertyGroup>
<ItemGroup>
<None Include=".env" />
<None Include="docker-compose.yml" />
<None Include="docker-compose.staging.yml">
<DependentUpon>docker-compose.yml</DependentUpon>
</None>
<None Include="docker-compose.production.yml">
<DependentUpon>docker-compose.yml</DependentUpon>
</None>
<None Include="docker-compose.override.yml">
<DependentUpon>docker-compose.yml</DependentUpon>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,9 @@
version: "3.8"
services:
web:
# optional: mount source for live edit during development
# volumes:
# - ./web:/src/web:cached
environment:
- DOTNET_ENVIRONMENT=Development
@@ -0,0 +1,69 @@
services:
api:
image: registry.easysoft.ro/apps/myai-api:production
container_name: myai-api
environment:
- APP_ENVIRONMENT_NAME=easySoft.ro-Production
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_URLS=http://+:8080
- Smtp__Host=mail.easysoft.ro
- Smtp__Port=587
- Smtp__Username=no-reply@myai.ro
- Smtp__Password=37,_,tunSis
- Smtp__UseStartTls=true
- FileStorage__Path=Files
- FileStorage__DefaultFileName=
- FileStorage__ToEmail=webmaster@myai.ro
- FileStorage__SubjectPrefix=[File Download]
- Captcha__Provider=Recaptcha
- Captcha__SecretKey=6LfR3NUsAAAAAP6ZDeJMmksyHZMkApQ29Kb4xZ5v
- Captcha__PublicKey=6LfR3NUsAAAAAH1bFYTKlgwp9SBKf5IRB2IOrhBe
- Captcha__MinimumScore=0.5
- Google__TagManagerId=GTM-NHWC9N2K
- Google__MapKey=
- Contact__ToEmail=contact@myai.ro
- Contact__SubjectPrefix=[Contact]
- Subscribe__ToEmail=contact@myai.ro
- Subscribe__SubjectPrefix=[Subscribe]
- Cors__AllowedOrigins__0=https://myai.ro
- Logging__LogLevel__Default=Information
- Logging__LogLevel__Microsoft=Warning
- Logging__LogLevel__Microsoft__AspNetCore=Warning
- Logging__LogLevel__Api=Information
- Serilog__WriteTo__2__Args__fromEmail=no-reply@myai.ro
- Serilog__WriteTo__2__Args__toEmail=webmaster@myai.ro
- Serilog__WriteTo__2__Args__mailServer=mail.easysoft.ro
- Serilog__WriteTo__2__Args__networkCredential__userName=no-reply@myai.ro
- Serilog__WriteTo__2__Args__networkCredential__password=37,_,tunSis
- Serilog__WriteTo__2__Args__port=587
- Serilog__WriteTo__2__Args__enableSsl=true
volumes:
- myai_api_logs:/app/logs
- /opt/easysoft/files:/app/Files
networks:
- myai-network
extra_hosts:
- "mail.easysoft.ro:10.0.0.225"
restart: unless-stopped
labels:
- "com.centurylinklabs.watchtower.enable=true"
web:
image: registry.easysoft.ro/apps/myai-web:production
container_name: myai-web
depends_on:
- api
ports:
- "5140:8080"
networks:
- myai-network
restart: unless-stopped
labels:
- "com.centurylinklabs.watchtower.enable=true"
networks:
myai-network:
driver: bridge
volumes:
myai_api_logs:
+69
View File
@@ -0,0 +1,69 @@
services:
api:
image: registry.easysoft.ro/apps/myai-api:staging
container_name: myai-api
environment:
- APP_ENVIRONMENT_NAME=myAi.ro-Staging
- ASPNETCORE_ENVIRONMENT=Staging
- ASPNETCORE_URLS=http://+:8080
- Smtp__Host=mail.easysoft.ro
- Smtp__Port=587
- Smtp__Username=no-reply-staging@easysoft.ro
- Smtp__Password=37,_,tunSis
- Smtp__UseStartTls=true
- FileStorage__Path=Files
- FileStorage__DefaultFileName=
- FileStorage__ToEmail=webmaster-staging@easysoft.ro
- FileStorage__SubjectPrefix=[File Download]
- Captcha__Provider=Recaptcha
- Captcha__SecretKey=6LfR3NUsAAAAAP6ZDeJMmksyHZMkApQ29Kb4xZ5v
- Captcha__PublicKey=6LfR3NUsAAAAAH1bFYTKlgwp9SBKf5IRB2IOrhBe
- Captcha__MinimumScore=0.5
- Google__TagManagerId=GTM-NHWC9N2K
- Google__MapKey=
- Contact__ToEmail=contact-staging@easysoft.ro
- Contact__SubjectPrefix=[Contact]
- Subscribe__ToEmail=contact-staging@easysoft.ro
- Subscribe__SubjectPrefix=[Subscribe]
- Cors__AllowedOrigins__0=https://myai.easysoft.ro
- Logging__LogLevel__Default=Information
- Logging__LogLevel__Microsoft=Warning
- Logging__LogLevel__Microsoft__AspNetCore=Warning
- Logging__LogLevel__Api=Information
- Serilog__WriteTo__2__Args__fromEmail=no-reply-staging@easysoft.ro
- Serilog__WriteTo__2__Args__toEmail=webmaster-staging@easysoft.ro
- Serilog__WriteTo__2__Args__mailServer=mail.easysoft.ro
- Serilog__WriteTo__2__Args__networkCredential__userName=no-reply-staging@easysoft.ro
- Serilog__WriteTo__2__Args__networkCredential__password=37,_,tunSis
- Serilog__WriteTo__2__Args__port=587
- Serilog__WriteTo__2__Args__enableSsl=true
volumes:
- myai_api_logs:/app/logs
- /opt/easysoft/files:/app/Files
networks:
- myai-network
extra_hosts:
- "mail.easysoft.ro:10.0.0.225"
restart: unless-stopped
labels:
- "com.centurylinklabs.watchtower.enable=true"
web:
image: registry.easysoft.ro/apps/myai-web:staging
container_name: myai-web
depends_on:
- api
ports:
- "5140:8080"
networks:
- myai-network
restart: unless-stopped
labels:
- "com.centurylinklabs.watchtower.enable=true"
networks:
myai-network:
driver: bridge
volumes:
myai_api_logs:
+45
View File
@@ -0,0 +1,45 @@
version: "3.8"
services:
api:
build:
context: ../api
dockerfile: Dockerfile
container_name: myai-api
ports:
- "8080:8080"
env_file:
- ../api/.env
- .env
environment:
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development}
- ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080}
- Cors__AllowedOrigins__0=http://localhost:5000
- Cors__AllowedOrigins__1=http://web:8080
volumes:
- ../api/logs:/app/logs
networks:
- myai-network
restart: unless-stopped
web:
build:
context: ../web
dockerfile: Dockerfile
container_name: myai-web
depends_on:
- api
ports:
- "5000:8080"
env_file:
- .env
environment:
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development}
- ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080}
networks:
- myai-network
restart: unless-stopped
networks:
myai-network:
driver: bridge
+36
View File
@@ -0,0 +1,36 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.2.11415.280
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "api", "api\api.csproj", "{16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "web", "web\web.csproj", "{B0A3EAB7-759A-448A-A906-52DF75A70016}"
EndProject
Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose\docker-compose.dcproj", "{81DDED9D-158B-E303-5F62-77A2896D2A5A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|Any CPU.Build.0 = Release|Any CPU
{B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|Any CPU.Build.0 = Release|Any CPU
{81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {6246A67B-299E-4E64-8DBE-1A66771E7C67}
EndGlobalSection
EndGlobal
+18
View File
@@ -0,0 +1,18 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src/web
COPY web.csproj ./
RUN dotnet restore web.csproj
# Copy only the web project files to avoid bringing other projects into the build context
COPY . ./
RUN dotnet publish web.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
EXPOSE 8080
ENV ASPNETCORE_URLS=http://0.0.0.0:8080
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "web.dll"]
+17
View File
@@ -0,0 +1,17 @@
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRouting();
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
// Static site
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapReverseProxy();
app.Run();
+15
View File
@@ -0,0 +1,15 @@
{
"profiles": {
"Container (Dockerfile)": {
"commandName": "Docker",
"launchBrowser": true,
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
"environmentVariables": {
"ASPNETCORE_HTTPS_PORTS": "8081",
"ASPNETCORE_HTTP_PORTS": "8080"
},
"publishAllPorts": true,
"useSSL": true
}
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ReverseProxy": {
"Routes": {
"apiRoute": {
"ClusterId": "apiCluster",
"Match": { "Path": "/api/{**catch-all}" }
}
},
"Clusters": {
"apiCluster": {
"Destinations": {
"api": { "Address": "http://api:8080/" }
}
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ReverseProxy": {
"Routes": {
"apiRoute": {
"ClusterId": "apiCluster",
"Match": { "Path": "/api/{**catch-all}" }
}
},
"Clusters": {
"apiCluster": {
"Destinations": {
"api": { "Address": "http://myai-api:8080/" }
}
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>e410da9c-f42f-43bd-86d7-47d051e2bad6</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
</Project>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+99
View File
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MyAi.ro · AI CV Matcher</title>
<meta name="description" content="Upload a CV PDF, provide a job link or description, and compare the job requirements against extracted CV context." />
<meta name="author" content="Mihes Gelu" />
<meta property="og:title" content="MyAi.ro · AI CV Matcher" />
<meta property="og:description" content="AI-powered CV-to-job matching demo." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://myai.ro/cv-matcher/" />
<meta name="theme-color" content="#071326" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/myai.css" />
</head>
<body>
<div class="site-shell">
<header class="header" id="top">
<div class="container nav-wrap">
<a class="brand" href="/" aria-label="MyAi.ro home"><span class="brand-mark ai-mark">AI</span><span><span class="brand-text">MyAi.ro</span><small>CV Matcher</small></span></a>
<nav class="nav" id="mainNav" aria-label="Primary navigation"><a href="/">Navigator</a><a href="#matcher">Matcher</a><a href="#contact">Contact</a></nav>
<button class="menu-toggle" id="menuToggle" aria-expanded="false" aria-controls="mainNav"><span></span><span></span><span></span></button>
</div>
</header>
<main>
<section class="hero matcher-hero">
<div class="container hero-grid">
<div class="hero-copy">
<span class="eyebrow">AI CV Matcher</span>
<h1>Upload your CV, add a job link, and see how well they match.</h1>
<p class="hero-text">The backend should extract text from the PDF, create RAG context from your CV, retrieve relevant experience for the job, then score strengths, gaps and next actions.</p>
<div class="hero-actions"><a class="btn btn-primary" href="#matcher">Try matcher</a><a class="btn btn-secondary" href="/">Back to navigator</a></div>
</div>
<div class="hero-card ai-console-card">
<div class="console-line"><span>1</span> PDF text extraction</div>
<div class="console-line"><span>2</span> CV chunking + embeddings</div>
<div class="console-line"><span>3</span> Job URL/description parsing</div>
<div class="console-line"><span>4</span> Match score + evidence</div>
</div>
</div>
</section>
<section class="section" id="matcher">
<div class="container matcher-grid">
<form class="ai-panel" id="cvMatcherForm">
<span class="eyebrow">Input</span>
<h2>CV and job details</h2>
<label class="file-drop" for="cvFile"><strong>Upload CV PDF</strong><span id="cvFileName">PDF only, max size handled by backend</span><input type="file" id="cvFile" accept="application/pdf" required /></label>
<label><span>Job link</span><input type="url" id="jobUrl" placeholder="https://company.com/careers/job" /></label>
<label><span>Or paste job description</span><textarea id="jobDescription" rows="8" placeholder="Paste the job description if the page cannot be crawled."></textarea></label>
<div class="consent-inline"><input type="checkbox" id="gdprConsent" required /><label for="gdprConsent">I agree that my CV is processed for this matching demo. Do not upload sensitive documents unless you trust the deployment.</label></div>
<button id="matchSubmit" type="submit" class="btn btn-primary">Extract CV and match job</button>
<strong id="matcherMsg" class="form-message"></strong>
</form>
<aside class="ai-panel result-panel">
<span class="eyebrow">Result</span>
<h2>Match analysis</h2>
<div id="matchResult" class="empty-result">Upload a CV and provide a job link or description to generate a result.</div>
</aside>
</div>
</section>
<section class="section contact" id="contact">
<div class="container contact-grid">
<div>
<span class="eyebrow">Contact</span>
<h2>Want this adapted for your workflow?</h2>
<p>This form uses the existing template contact API endpoint.</p>
<div class="contact-list"><div><span>Contact person</span><strong>Mihes Gelu</strong></div><div><span>Phone</span><strong><a href="tel:+40722523764">+40 722-523-764</a></strong></div></div>
</div>
<form class="contact-form" id="contactForm">
<label><span>Name</span><input type="text" id="name" placeholder="Your name" required /></label>
<label><span>Email</span><input type="email" id="email" placeholder="name@company.com" required /></label>
<label><span>Message</span><textarea id="message" rows="6" placeholder="Tell me what you want to build." required></textarea></label>
<button id="submit" type="submit" class="btn btn-primary">Send message</button>
<strong id="msgSubmit" class="form-message"></strong>
</form>
</div>
</section>
</main>
<footer class="footer"><div class="container footer-wrap"><p>© <span id="year"></span> MyAi.ro · All rights reserved</p><div class="footer-links footer-legal"><a href="/legal/terms-en.html" target="_blank">Terms</a><a href="/legal/privacy-en.html" target="_blank">Privacy</a><a href="/legal/cookies-en.html" target="_blank">Cookies</a></div><a href="#top" class="back-to-top btn btn-dark btn-sm shadow">Back to top</a></div></footer>
</div>
<div id="contactLoader" class="loader-overlay" style="display:none;"><div class="loader-box">Sending...</div></div>
<div id="cookieBanner" class="cookie-overlay" style="display:none;"><div class="cookie-box"><div class="cookie-text"><strong>Cookies</strong><br>We use necessary cookies and, with your consent, analytics through Google Tag Manager. <a href="/legal/privacy-en.html" target="_blank">Privacy policy</a>.</div><div class="cookie-actions"><button id="cookieReject" class="btn btn-warning btn-sm">Reject</button><button id="cookieNecessary" class="btn btn-warning btn-sm">Necessary only</button><button id="cookieAccept" class="btn btn-primary btn-sm">Accept analytics</button></div></div></div>
<a href="#" id="cookieManage" class="cookie-manage btn btn-dark btn-sm shadow" style="display:none;">Cookie settings</a>
<script src="/js/vendor/jquery-1.12.4.min.js"></script>
<script src="/js/bootstrap.min.js"></script>
<script src="/js/myai.js"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800" fill="none">
<rect width="1200" height="800" rx="36" fill="#081120"/>
<rect x="75" y="75" width="1050" height="650" rx="30" fill="#12213A" stroke="#56B6FF" stroke-opacity="0.25"/>
<rect x="150" y="150" width="320" height="500" rx="28" fill="#173255"/>
<rect x="540" y="170" width="470" height="70" rx="18" fill="#21406E"/>
<rect x="540" y="280" width="400" height="30" rx="15" fill="#AFBDD4" fill-opacity="0.7"/>
<rect x="540" y="340" width="430" height="30" rx="15" fill="#AFBDD4" fill-opacity="0.45"/>
<rect x="540" y="430" width="250" height="130" rx="22" fill="#56B6FF" fill-opacity="0.9"/>
<rect x="820" y="430" width="190" height="130" rx="22" fill="#7DF0C8" fill-opacity="0.85"/>
<path d="M310 255C365.228 255 410 299.772 410 355V430H370V355C370 321.863 343.137 295 310 295C276.863 295 250 321.863 250 355V430H210V355C210 299.772 254.772 255 310 255Z" fill="#7DF0C8"/>
<rect x="190" y="390" width="240" height="170" rx="24" fill="#0F1B31" stroke="#7DF0C8" stroke-width="10"/>
<text x="145" y="690" fill="#EEF4FF" font-family="Arial, sans-serif" font-size="54" font-weight="700">easyBackup</text>
<text x="145" y="740" fill="#AFBDD4" font-family="Arial, sans-serif" font-size="28">Replace with your real product screenshot</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800" fill="none">
<rect width="1200" height="800" rx="36" fill="#0B172A"/>
<rect x="80" y="80" width="1040" height="640" rx="30" fill="#13233F" stroke="#56B6FF" stroke-opacity="0.25"/>
<rect x="140" y="150" width="380" height="420" rx="22" fill="#1B3155"/>
<rect x="570" y="150" width="480" height="70" rx="18" fill="#21406E"/>
<rect x="570" y="260" width="430" height="28" rx="14" fill="#7DF0C8" fill-opacity="0.7"/>
<rect x="570" y="316" width="360" height="28" rx="14" fill="#7DF0C8" fill-opacity="0.5"/>
<rect x="570" y="372" width="410" height="28" rx="14" fill="#7DF0C8" fill-opacity="0.35"/>
<rect x="570" y="448" width="190" height="56" rx="18" fill="#56B6FF"/>
<circle cx="330" cy="300" r="110" fill="#56B6FF" fill-opacity="0.18"/>
<path d="M330 212C374.183 212 410 247.817 410 292C410 355 330 430 330 430C330 430 250 355 250 292C250 247.817 285.817 212 330 212Z" fill="#7DF0C8"/>
<text x="140" y="645" fill="#EEF4FF" font-family="Arial, sans-serif" font-size="54" font-weight="700">easyDent</text>
<text x="140" y="695" fill="#AFBDD4" font-family="Arial, sans-serif" font-size="28">Replace with your real product screenshot</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800" fill="none">
<rect width="1200" height="800" rx="36" fill="#091423"/>
<rect x="80" y="80" width="1040" height="640" rx="28" fill="#13233F" stroke="#7DF0C8" stroke-opacity="0.22"/>
<rect x="130" y="145" width="250" height="470" rx="24" fill="#19304F"/>
<rect x="420" y="145" width="630" height="110" rx="24" fill="#1D3C67"/>
<rect x="420" y="290" width="300" height="145" rx="24" fill="#56B6FF" fill-opacity="0.9"/>
<rect x="750" y="290" width="300" height="145" rx="24" fill="#7DF0C8" fill-opacity="0.85"/>
<rect x="420" y="470" width="630" height="145" rx="24" fill="#1B3155"/>
<rect x="170" y="195" width="170" height="16" rx="8" fill="#AFBDD4"/>
<rect x="170" y="245" width="110" height="16" rx="8" fill="#AFBDD4" fill-opacity="0.75"/>
<rect x="170" y="295" width="140" height="16" rx="8" fill="#AFBDD4" fill-opacity="0.55"/>
<text x="130" y="678" fill="#EEF4FF" font-family="Arial, sans-serif" font-size="54" font-weight="700">easyERP</text>
<text x="130" y="728" fill="#AFBDD4" font-family="Arial, sans-serif" font-size="28">Replace with your real product screenshot</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="32" viewBox="0 0 48 32">
<clipPath id="a"><rect width="48" height="32" rx="2"/></clipPath>
<g clip-path="url(#a)">
<rect width="48" height="32" fill="#012169"/>
<path d="M0 0l48 32M48 0L0 32" stroke="#fff" stroke-width="6"/>
<path d="M0 0l48 32M48 0L0 32" stroke="#C8102E" stroke-width="3"/>
<path d="M24 0v32M0 16h48" stroke="#fff" stroke-width="10"/>
<path d="M24 0v32M0 16h48" stroke="#C8102E" stroke-width="6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 513 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="32" viewBox="0 0 48 32">
<rect width="16" height="32" fill="#002B7F"/>
<rect x="16" width="16" height="32" fill="#FCD116"/>
<rect x="32" width="16" height="32" fill="#CE1126"/>
</svg>

After

Width:  |  Height:  |  Size: 249 B

+144
View File
@@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MyAi.ro · AI Engineering Showcase</title>
<meta name="description" content="MyAi.ro showcases practical AI engineering demos, including a CV-to-job matcher powered by document extraction, retrieval and AI scoring." />
<meta name="author" content="Mihes Gelu" />
<meta property="og:title" content="MyAi.ro · AI Engineering Showcase" />
<meta property="og:description" content="Practical AI demos built by a veteran software developer." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://myai.ro/" />
<meta name="theme-color" content="#071326" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/bootstrap.min.css" />
<link rel="stylesheet" href="css/myai.css" />
</head>
<body>
<div class="site-shell">
<header class="header" id="top">
<div class="container nav-wrap">
<a class="brand" href="/" aria-label="MyAi.ro home">
<span class="brand-mark ai-mark">AI</span>
<span>
<span class="brand-text">MyAi.ro</span>
<small>AI engineering showcase</small>
</span>
</a>
<nav class="nav" id="mainNav" aria-label="Primary navigation">
<a href="#demos">Demos</a>
<a href="#contact">Contact</a>
</nav>
<button class="menu-toggle" id="menuToggle" aria-expanded="false" aria-controls="mainNav">
<span></span><span></span><span></span>
</button>
</div>
</header>
<main>
<section class="hero navigator-hero">
<div class="container hero-grid">
<div class="hero-copy">
<span class="eyebrow">Applied AI lab</span>
<h1>Production-minded AI demos, not generic chatbot wrappers.</h1>
<p class="hero-text">MyAi.ro is a technical showcase for practical AI systems: document understanding, retrieval, matching, automation and decision support.</p>
<div class="hero-actions">
<a class="btn btn-primary" href="/cv-matcher/">Open CV Matcher</a>
<a class="btn btn-secondary" href="#contact">Contact</a>
</div>
</div>
<div class="hero-card ai-console-card">
<div class="console-line"><span>upload</span> CV.pdf</div>
<div class="console-line"><span>extract</span> skills, projects, experience</div>
<div class="console-line"><span>retrieve</span> relevant CV context</div>
<div class="console-line"><span>score</span> job match + gaps</div>
</div>
</div>
</section>
<section class="section" id="demos">
<div class="container">
<div class="section-heading">
<span class="eyebrow">Navigator</span>
<h2>Select an AI demo</h2>
<p>Start with the CV Matcher. More demos can be added here without changing the site structure.</p>
</div>
<div class="demo-grid">
<a class="demo-card active" href="/cv-matcher/">
<span class="product-tag">Available</span>
<h3>CV Matcher</h3>
<p>Upload a CV PDF, provide a job link or description, extract RAG context and generate a match score with strengths and gaps.</p>
<strong>Open demo →</strong>
</a>
<article class="demo-card muted-card">
<span class="product-tag">Next</span>
<h3>RAG Playground</h3>
<p>Experiment with chunk size, retrieval count and source context transparency.</p>
</article>
<article class="demo-card muted-card">
<span class="product-tag">Next</span>
<h3>Agent Automation</h3>
<p>Job discovery, filtering, ranking and notification workflows.</p>
</article>
</div>
</div>
</section>
<section class="section contact" id="contact">
<div class="container contact-grid">
<div>
<span class="eyebrow">Contact</span>
<h2>Discuss an AI integration or custom software project</h2>
<p>Use the form and it will submit through the existing contact API endpoint from the template.</p>
<div class="contact-list">
<div><span>Contact person</span><strong>Mihes Gelu</strong></div>
<div><span>Phone</span><strong><a href="tel:+40722523764">+40 722-523-764</a></strong></div>
<div><span>WhatsApp</span><strong><a href="https://wa.me/40744564177" target="_blank" rel="noreferrer">+40 744-564-177</a></strong></div>
</div>
</div>
<form class="contact-form" id="contactForm">
<label><span>Name</span><input type="text" id="name" placeholder="Your name" required /></label>
<label><span>Email</span><input type="email" id="email" placeholder="name@company.com" required /></label>
<label><span>Message</span><textarea id="message" rows="6" placeholder="Tell me what you want to build." required></textarea></label>
<button id="submit" type="submit" class="btn btn-primary">Send message</button>
<strong id="msgSubmit" class="form-message"></strong>
</form>
</div>
</section>
</main>
<footer class="footer">
<div class="container footer-wrap">
<p>© <span id="year"></span> MyAi.ro · All rights reserved</p>
<div class="footer-links footer-legal">
<a href="/legal/terms-en.html" target="_blank">Terms</a>
<a href="/legal/privacy-en.html" target="_blank">Privacy</a>
<a href="/legal/cookies-en.html" target="_blank">Cookies</a>
</div>
<a href="#top" class="back-to-top btn btn-dark btn-sm shadow">Back to top</a>
</div>
</footer>
</div>
<div id="contactLoader" class="loader-overlay" style="display:none;"><div class="loader-box">Sending...</div></div>
<div id="cookieBanner" class="cookie-overlay" style="display:none;">
<div class="cookie-box">
<div class="cookie-text"><strong>Cookies</strong><br>We use necessary cookies and, with your consent, analytics through Google Tag Manager. <a href="/legal/privacy-en.html" target="_blank">Privacy policy</a>.</div>
<div class="cookie-actions">
<button id="cookieReject" class="btn btn-warning btn-sm">Reject</button>
<button id="cookieNecessary" class="btn btn-warning btn-sm">Necessary only</button>
<button id="cookieAccept" class="btn btn-primary btn-sm">Accept analytics</button>
</div>
</div>
</div>
<a href="#" id="cookieManage" class="cookie-manage btn btn-dark btn-sm shadow" style="display:none;">Cookie settings</a>
<script src="js/vendor/jquery-1.12.4.min.js"></script>
<script src="js/bootstrap.min.js"></script>
<script src="js/myai.js"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
+490
View File
@@ -0,0 +1,490 @@
var translations = {
ro: {
'brand.tagline': 'Romania dezvoltare applicatii',
'nav.products': 'Produse', 'nav.services': 'Servicii', 'nav.contact': 'Contactati-ne',
'hero.eyebrow': 'Portofoliu software easySoft',
'hero.title': 'Software pentru cabinete dentare, gestiune de stocuri si salvare sigura a datelor.',
'hero.text': 'Aplicatii practice construite pentru fluxuri reale de lucru: managementul pacientilor, contabilitate si gestiune, plus backup automatizat pentru fisiere importante.',
'hero.ctaPrimary': 'Vezi produsele', 'hero.ctaSecondary': 'Contactati-ne',
'hero.metric1': 'aplicații software', 'hero.metric2': 'ani experiență în dezvoltare', 'hero.metric3': 'instalări active',
'hero.cardLabel': 'Portofoliu software', 'hero.badge1': 'cabinete dentare', 'hero.badge2': 'gestiune si contabilitate', 'hero.badge3': 'salvare automata',
'products.eyebrow': 'Produse', 'products.title': 'Aplicatii software pentru activitati de zi cu zi', 'products.text': 'Portofoliul actual include aplicatii pentru cabinete stomatologice, operatiuni comerciale si protectia datelor.',
'products.easydent.title': 'Soft pentru cabinete stomatologice',
'products.easydent.desc': 'easyDent este un program destinat cabinetelor stomatologice si clinicilor dentare. Puteti urmari pacienti, plati, interventii, pacienti restantieri si profitul pe perioade de timp.',
'products.easydent.feature1': 'Planificarea pacientilor intr-o sectiune simplu de utilizat', 'products.easydent.feature2': 'Imagini dentare, istoric pe dinte si poze atasate pacientilor', 'products.easydent.feature3': 'Raport lunar CNAS si configurare pe specializari',
'products.easyerp.title': 'Soft de contabilitate si gestiunea stocurilor',
'products.easyerp.desc': 'easyERP este un program client-server, bazat pe SQL, compus din module pentru diferite divizii ale companiei: gestiune, contabilitate, facturare, livrari, comenzi si promotii.',
'products.easyerp.feature1': 'Receptie de marfa, inventar si cautare rapida in nomenclatoare', 'products.easyerp.feature2': 'Control pe roluri si drepturi de acces pe module', 'products.easyerp.feature3': 'Documente comerciale si rapoarte operationale',
'products.easybackup.title': 'Utilitar pentru salvarea fisierelor',
'products.easybackup.desc': 'easyBackup salveaza fisiere in directoare partajate sau pe servere FTP, cu job-uri recurente sau simple, monitorizare in timp si optiuni de compresie si notificare.',
'products.easybackup.feature1': 'Definire job-uri recurente sau simple', 'products.easybackup.feature2': 'Scanare directoare cu excluderi si filtre', 'products.easybackup.feature3': 'Compresie, parola si notificari prin e-mail',
'products.download': 'Descarca',
'services.eyebrow': 'Servicii', 'services.title': 'Servicii software si consultanta IT',
'services.db.title': 'Proiectare baze de date', 'services.db.text': 'Organizati mai bine informatiile si obtineti rapoartele de care aveti nevoie.',
'services.it.title': 'Consultanta IT', 'services.it.text': 'Analizam ofertele disponibile si alegem sistemul informatic potrivit pentru activitatea dumneavoastra.',
'services.dev.title': 'Dezvoltare software', 'services.dev.text': 'Construim aplicatii adaptate fluxului real de lucru, nu invers.',
'about.eyebrow': 'easySoft', 'about.title': 'Solutii software construite pentru lucru real',
'about.text1': 'Portofoliul easySoft acopera activitati esentiale pentru cabinete stomatologice, evidenta comerciala si protectia fisierelor importante.',
'about.text2': 'Aplicatiile sunt gandite pentru utilizare practica, implementare rapida si acces clar la informatiile care conteaza in activitatea zilnica.',
'about.point1Title': 'Implementare practica', 'about.point1Text': 'Produse software orientate spre utilizare zilnica.',
'about.point2Title': 'Fluxuri clare', 'about.point2Text': 'Planificare, evidenta, facturare, rapoarte si backup in functii usor de urmarit.',
'about.point3Title': 'Suport comercial direct', 'about.point3Text': 'Pentru prezentare, oferta sau implementare, puteti lua legatura direct cu easySoft.',
'contact.eyebrow': 'Contactati-ne', 'contact.title': 'Discutam despre nevoile tale software',
'contact.text': 'easySoft Romania dezvoltare applicatii. Pentru prezentari, detalii comerciale sau implementare, folositi datele de mai jos.',
'contact.personLabel': 'Persoana de contact', 'contact.addressLabel': 'Adresa', 'contact.phoneLabel': 'Telefon', 'contact.webLabel': 'Website',
'contact.form.name': 'Nume', 'contact.form.email': 'Email', 'contact.form.message': 'Mesaj', 'contact.form.send': 'Trimite mesaj',
'contact.form.yourname': 'Numele dumneavoastra', 'contact.form.youremail': 'nume@companie.ro', 'contact.form.yourmessage': 'Spuneti-ne ce produs sau serviciu va intereseaza.',
'contact.form.success': 'Va multumim pentru mesaj.', 'contact.form.submissionfailed': 'A aparut o eroare la transmiterea mesajului.', 'contact.form.verificationfailed': 'A aparut o eroare la verificare Captcha.',
'footer.backToTop': 'Inapoi sus',
'cookie.text': 'Folosim cookie-uri pentru a imbunatati experienta pe site, a analiza traficul si a personaliza continutul. Puteti accepta toate cookie-urile sau doar cele necesare.',
'cookie.privacy': 'Politica de confidentialitate',
'cookie.reject': 'Respinge',
'cookie.necessary': 'Doar necesare',
'cookie.accept': 'Accepta analitice',
'footer.rights': 'Toate drepturile rezervate'
},
en: {
'brand.tagline': 'Romania software development',
'nav.products': 'Products', 'nav.services': 'Services', 'nav.contact': 'Contact us',
'hero.eyebrow': 'easySoft software portfolio',
'hero.title': 'Software for dental offices, stock management and secure data backup.',
'hero.text': 'Practical applications built for real workflows: patient management, accounting and inventory, plus automated backup for critical files.',
'hero.ctaPrimary': 'View products', 'hero.ctaSecondary': 'Contact us',
'hero.metric1': 'software solutions', 'hero.metric2': 'years experience in software development', 'hero.metric3': 'active installations',
'hero.cardLabel': 'Software portfolio', 'hero.badge1': 'dental offices', 'hero.badge2': 'inventory and accounting', 'hero.badge3': 'automatic backup',
'products.eyebrow': 'Products', 'products.title': 'Software applications for daily operations', 'products.text': 'The current portfolio includes applications for dental offices, business operations and data protection.',
'products.easydent.title': 'Software for dental offices',
'products.easydent.desc': 'easyDent is built for dental offices and clinics. It helps track patients, payments, interventions, outstanding balances and profit over time.',
'products.easydent.feature1': 'Simple patient scheduling section', 'products.easydent.feature2': 'Dental images, tooth history and attached patient photos', 'products.easydent.feature3': 'Monthly reports and specialization-based setup',
'products.easyerp.title': 'Accounting and stock management software',
'products.easyerp.desc': 'easyERP is a client-server SQL-based application with modules for company divisions: inventory, accounting, invoicing, deliveries, orders and promotions.',
'products.easyerp.feature1': 'Receiving merchandise, inventory and fast search', 'products.easyerp.feature2': 'Role-based permissions for modules', 'products.easyerp.feature3': 'Commercial documents and operational reports',
'products.easybackup.title': 'File backup utility',
'products.easybackup.desc': 'easyBackup saves files to shared folders or FTP servers using recurring or one-time jobs, change tracking, compression and notifications.',
'products.easybackup.feature1': 'Recurring or one-time backup jobs', 'products.easybackup.feature2': 'Directory scanning with exclusions and filters', 'products.easybackup.feature3': 'Compression, password protection and email alerts',
'products.download': 'Download',
'services.eyebrow': 'Services', 'services.title': 'Software services and IT consulting',
'services.db.title': 'Database design', 'services.db.text': 'Organize information better and get the reports you actually need.',
'services.it.title': 'IT consulting', 'services.it.text': 'We review available systems and help choose the right software solution.',
'services.dev.title': 'Software development', 'services.dev.text': 'We build applications around the real workflow, not the other way around.',
'about.eyebrow': 'easySoft', 'about.title': 'Software solutions built for real work',
'about.text1': 'The easySoft portfolio covers essential workflows for dental offices, commercial operations and protection of important files.',
'about.text2': 'The applications are built for practical use, fast implementation and clear access to the information that matters every day.',
'about.point1Title': 'Practical implementation', 'about.point1Text': 'Software products focused on daily use.',
'about.point2Title': 'Clear workflows', 'about.point2Text': 'Scheduling, records, invoicing, reports and backup in functions that are easy to follow.',
'about.point3Title': 'Direct commercial support', 'about.point3Text': 'For presentation, pricing or implementation, you can contact easySoft directly.',
'contact.eyebrow': 'Contact us', 'contact.title': 'Lets discuss your software needs',
'contact.text': 'easySoft Romania software development. For product presentations, commercial details or implementation, use the contact details below.',
'contact.personLabel': 'Contact person', 'contact.addressLabel': 'Address', 'contact.phoneLabel': 'Phone', 'contact.webLabel': 'Website',
'contact.form.name': 'Name', 'contact.form.email': 'Email', 'contact.form.message': 'Message', 'contact.form.send': 'Send message',
'contact.form.yourname': 'Your name', 'contact.form.youremail': 'name@company.com', 'contact.form.yourmessage': 'Let us know how we can help you.',
'contact.form.success': 'Thank you for your message.', 'contact.form.submissionfailed': 'Failed to send the message.', 'contact.form.verificationfailed': 'Captcha verification failed.',
'footer.backToTop': 'Back to top',
'cookie.text': 'We use cookies to enhance your experience, analyze traffic and personalize content. You can accept all cookies or just the necessary ones.',
'cookie.privacy': 'Privacy policy',
'cookie.reject': 'Reject',
'cookie.necessary': 'Necessary only',
'cookie.accept': 'Accept analytics',
'footer.rights': 'All rights reserved'
}
};
function updateLegalLinks(lang) {
$('.legal-link').each(function () {
var $link = $(this);
if (lang === 'ro') {
$link.attr('href', $link.attr('data-ro-href'));
$link.text($link.attr('data-ro-text'));
} else {
$link.attr('href', $link.attr('data-en-href'));
$link.text($link.attr('data-en-text'));
}
});
}
function applyLanguage(lang) {
var dict = translations[lang] || translations.en;
$('[data-i18n]').each(function () {
var key = $(this).data('i18n');
if (!key) return;
// Handle special attributes like [placeholder]
var match = key.match(/^\[(.*?)\](.*)$/);
if (match) {
var attr = match[1]; // e.g. "placeholder"
var realKey = match[2]; // e.g. "contact.form.message"
if (dict[realKey]) {
$(this).attr(attr, dict[realKey]);
}
} else {
if (dict[key]) {
$(this).text(dict[key]);
}
}
});
updateLegalLinks(lang);
$('html').attr('lang', lang);
localStorage.setItem('easysoft-language', lang);
$('.lang-flag').each(function () {
var isActive = $(this).data('lang') === lang;
$(this).attr('aria-pressed', isActive ? 'true' : 'false');
$(this).toggleClass('is-active', isActive);
});
}
function detectLanguage() {
var saved = localStorage.getItem('easysoft-language');
if (saved && translations[saved]) {
return saved;
}
var browserLang = ((navigator.languages && navigator.languages[0]) || navigator.language || navigator.userLanguage || 'en').toLowerCase();
if (browserLang.indexOf('ro') === 0) {
return 'ro';
}
return 'en';
}
function closeMobileMenu() {
$('#mainNav').removeClass('is-open');
$('#menuToggle').attr('aria-expanded', 'false');
}
function initCarousels() {
$('[data-carousel]').each(function () {
var $carousel = $(this);
var $slides = $carousel.find('.carousel-slide');
var $dotsWrap = $carousel.find('.carousel-dots');
var index = 0;
var timer = null;
function renderDots() {
$dotsWrap.empty();
$slides.each(function (i) {
var $dot = $('<button>', {
type: 'button',
class: 'carousel-dot' + (i === index ? ' active' : ''),
'aria-label': 'Go to slide ' + (i + 1)
});
$dot.on('click', function () {
show(i);
restartAutoPlay();
});
$dotsWrap.append($dot);
});
}
function show(i) {
index = (i + $slides.length) % $slides.length;
$slides.removeClass('active').eq(index).addClass('active');
$dotsWrap.find('.carousel-dot').removeClass('active').eq(index).addClass('active');
}
function restartAutoPlay() {
if (timer) {
window.clearInterval(timer);
}
timer = window.setInterval(function () {
show(index + 1);
}, 5000);
}
$carousel.find('.prev').on('click', function () {
show(index - 1);
restartAutoPlay();
});
$carousel.find('.next').on('click', function () {
show(index + 1);
restartAutoPlay();
});
renderDots();
show(0);
restartAutoPlay();
});
}
(function ($) {
"use strict";
var reCaptchaSiteKey; // To be initialized from the backend API
var gTagManagerId; // Google Tag Manager ID from backend
var gMapKey; // To be initialized from the backend API
const CONSENT_KEY = "cookie_consent";
applyLanguage(detectLanguage());
$('.lang-flag').on('click', function () {
applyLanguage($(this).data('lang'));
});
$('#menuToggle').on('click', function () {
var $nav = $('#mainNav');
var open = !$nav.hasClass('is-open');
$nav.toggleClass('is-open', open);
$(this).attr('aria-expanded', open ? 'true' : 'false');
});
$('.nav a').on('click', function () {
closeMobileMenu();
});
$('#year').text(new Date().getFullYear());
/*--------------------------
API health check
---------------------------- */
// Checks the backend API live endpoint and updates #api-status if present.
function checkApiLive() {
var $statusEl = $('#api-status');
return $.ajax({
url: '/api/health/live',
method: 'GET',
dataType: 'json',
timeout: 5000
}).done(function (data) {
var status = (data && data.status) ? data.status : 'unknown';
if ($statusEl.length) {
$statusEl.text('API: ' + status).removeClass('text-danger').addClass('text-success');
} else {
console.log('API live status:', status);
}
}).fail(function (jqXHR, textStatus, errorThrown) {
if ($statusEl.length) {
$statusEl.text('API: offline').removeClass('text-success').addClass('text-danger');
}
console.error('API health check failed:', textStatus, errorThrown);
});
}
/*--------------------------
reCaptcha
---------------------------- */
function getRecaptchaWebKey() {
return $.get("/api/contact", function (res) {
reCaptchaSiteKey = res;
if (reCaptchaSiteKey) {
var script = document.createElement('script');
script.setAttribute('src', "https://www.google.com/recaptcha/api.js?render=" + reCaptchaSiteKey);
document.head.appendChild(script);
}
});
}
/*--------------------------
Google Tag Manager ID
---------------------------- */
function getGoogleTagManagerId() {
return $.get("/api/google/tagmanager", function (res) {
gTagManagerId = res;
});
}
/*--------------------------
Google Maps Key
---------------------------- */
function getGMapKey() {
return $.get("/api/google/maps", function (res) {
gMapKey = res;
if (gMapKey) {
var script = document.createElement('script');
script.setAttribute('src', "https://maps.googleapis.com/maps/api/js?key=" + gMapKey);
document.body.appendChild(script);
var script = document.createElement('script');
script.setAttribute('src', "js/mapcode.js");
document.body.appendChild(script);
}
});
}
/*--------------------------
Load Google Tag Manager script with the retrieved ID
---------------------------- */
function loadGoogleTagManager() {
if (window.__gtm_loaded) return;
var script = document.createElement('script');
script.async = true;
script.src = "https://www.googletagmanager.com/gtm/js?id=" + gTagManagerId;
document.head.appendChild(script);
// Initialize dataLayer
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'gtm.start': new Date().getTime(),
event: 'gtm.js'
});
}
/*--------------------------
Load consent cookie data
---------------------------- */
function loadConsent() {
var consent = getConsent();
if (!consent) {
showBanner();
} else {
applyConsent(consent);
showManage();
}
}
/*--------------------------
Preloader
---------------------------- */
$(window).on('load', async function () {
var pre_loader = $('#preloader')
try {
await Promise.all([
checkApiLive(),
getRecaptchaWebKey(),
getGoogleTagManagerId(),
getGMapKey()
]).then(loadConsent);
} catch (e) {
console.error("Startup API error", e);
}
pre_loader.fadeOut('slow', function () {
$(this).remove();
});
});
/*--------------------------
contact-from
---------------------------- */
$("#contactForm").on("submit", function (event) {
if (event.isDefaultPrevented()) {
// handle the invalid form...
formError();
submitMSG(false, "Did you fill in the form properly?");
} else {
// everything looks good!
event.preventDefault();
submitForm();
}
});
function submitForm() {
var loader = $('#contactLoader')
var button = $("#submit");
loader.css("display", "flex"); // show overlay
button.prop("disabled", true);
$("#msgSubmit").text("");
grecaptcha.ready(function () {
grecaptcha.execute(reCaptchaSiteKey, { action: 'contact' })
.then(function (token) {
// Initiate Variables With Form Content
var message = {
Name: $("#name").val(),
Email: $("#email").val(),
Subject: '[Contact request]',
Message: $("#message").val(),
CaptchaToken: token
};
$.ajax({
type: "POST",
url: "/api/contact",
data: JSON.stringify(message),
contentType: "application/json; charset=utf-8",
dataType: "json",
success: function (resp) {
if (resp && resp.ok === true) {
formSuccess()
} else {
var dict = translations[detectLanguage()] || translations.en;
submitMSG(false, dict['contact.form.verificationfailed']);
}
},
error: function () {
var dict = translations[detectLanguage()] || translations.en;
submitMSG(false, dict['contact.form.submissionfailed']);
}
}).always(function () {
loader.hide();
button.prop("disabled", false);
});
});
});
}
function formSuccess() {
$("#contactForm")[0].reset();
var dict = translations[detectLanguage()] || translations.en;
submitMSG(true, dict['contact.form.success'])
}
function formError() {
$("#contactForm").removeClass().addClass('shake animated').one('webkitAnimationEnd mozAnimationEnd MSAnimationEnd oanimationend animationend', function () {
$(this).removeClass();
});
}
function submitMSG(valid, msg) {
if (valid) {
var msgClasses = "text-center tada animated text-success";
} else {
var msgClasses = "text-center text-danger";
}
$("#msgSubmit").removeClass().addClass(msgClasses).text(msg);
}
/*--------------------------
cookie consent
---------------------------- */
function getConsent() {
try {
return JSON.parse(localStorage.getItem(CONSENT_KEY));
} catch {
return null;
}
}
function setConsent(consent) {
localStorage.setItem(CONSENT_KEY, JSON.stringify(consent));
}
function showBanner() { $("#cookieBanner").fadeIn(200); }
function hideBanner() { $("#cookieBanner").fadeOut(200); }
function showManage() { $("#cookieManage").show(); }
function applyConsent(consent) {
if (consent && consent.analytics === true) {
loadGoogleTagManager();
}
}
// Actions
$("#cookieReject, #cookieNecessary").on("click", function () {
setConsent({ necessary: true, analytics: false, ts: new Date().toISOString() });
hideBanner();
showManage();
location.reload(true);
});
$("#cookieAccept").on("click", function () {
setConsent({ necessary: true, analytics: true, ts: new Date().toISOString() });
hideBanner();
showManage();
applyConsent(getConsent());
location.reload(true);
});
$("#cookieManage").on("click", function (e) {
e.preventDefault();
showBanner();
});
initCarousels();
})(jQuery);
+243
View File
@@ -0,0 +1,243 @@
(function ($) {
"use strict";
var reCaptchaSiteKey = null;
var gTagManagerId = null;
var CONSENT_KEY = "myai_cookie_consent";
$('#year').text(new Date().getFullYear());
$('#menuToggle').on('click', function () {
var $nav = $('#mainNav');
var open = !$nav.hasClass('is-open');
$nav.toggleClass('is-open', open);
$(this).attr('aria-expanded', open ? 'true' : 'false');
});
$('.nav a').on('click', function () {
$('#mainNav').removeClass('is-open');
$('#menuToggle').attr('aria-expanded', 'false');
});
function getRecaptchaWebKey() {
return $.get('/api/contact').done(function (res) {
reCaptchaSiteKey = res;
if (reCaptchaSiteKey && !window.__recaptcha_loaded) {
window.__recaptcha_loaded = true;
var script = document.createElement('script');
script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?render=' + reCaptchaSiteKey);
document.head.appendChild(script);
}
}).fail(function () {
console.warn('Could not load reCaptcha site key from /api/contact');
});
}
function getGoogleTagManagerId() {
return $.get('/api/google/tagmanager').done(function (res) {
gTagManagerId = res;
}).fail(function () {
console.warn('Could not load Google Tag Manager id from /api/google/tagmanager');
});
}
function loadGoogleTagManager() {
if (window.__gtm_loaded || !gTagManagerId) return;
window.__gtm_loaded = true;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
var script = document.createElement('script');
script.async = true;
script.src = 'https://www.googletagmanager.com/gtm/js?id=' + gTagManagerId;
document.head.appendChild(script);
}
function getConsent() {
try { return JSON.parse(localStorage.getItem(CONSENT_KEY)); } catch { return null; }
}
function setConsent(consent) {
localStorage.setItem(CONSENT_KEY, JSON.stringify(consent));
}
function applyConsent(consent) {
if (consent && consent.analytics === true) loadGoogleTagManager();
}
function showBanner() { $('#cookieBanner').fadeIn(200); }
function hideBanner() { $('#cookieBanner').fadeOut(200); }
function showManage() { $('#cookieManage').show(); }
$('#cookieReject, #cookieNecessary').on('click', function () {
setConsent({ necessary: true, analytics: false, ts: new Date().toISOString() });
hideBanner();
showManage();
});
$('#cookieAccept').on('click', function () {
var consent = { necessary: true, analytics: true, ts: new Date().toISOString() };
setConsent(consent);
applyConsent(consent);
hideBanner();
showManage();
});
$('#cookieManage').on('click', function (e) {
e.preventDefault();
showBanner();
});
function initConsent() {
var consent = getConsent();
if (!consent) showBanner();
else { applyConsent(consent); showManage(); }
}
function submitMSG(valid, msg) {
var msgClasses = valid ? 'form-message text-success' : 'form-message text-danger';
$('#msgSubmit').removeClass().addClass(msgClasses).text(msg);
}
function formSuccess() {
$('#contactForm')[0].reset();
submitMSG(true, 'Thank you for your message.');
}
function formError() {
$('#contactForm').removeClass().addClass('contact-form shake').one('animationend', function () {
$(this).removeClass('shake');
});
}
$('#contactForm').on('submit', function (event) {
event.preventDefault();
var loader = $('#contactLoader');
var button = $('#submit');
loader.css('display', 'flex');
button.prop('disabled', true);
$('#msgSubmit').text('');
function postContact(token) {
var message = {
Name: $('#name').val(),
Email: $('#email').val(),
Subject: '[MyAi.ro contact request]',
Message: $('#message').val(),
CaptchaToken: token || ''
};
$.ajax({
type: 'POST',
url: '/api/contact',
data: JSON.stringify(message),
contentType: 'application/json; charset=utf-8',
dataType: 'json'
}).done(function (resp) {
if (resp && resp.ok === true) formSuccess();
else submitMSG(false, 'Captcha verification failed.');
}).fail(function () {
submitMSG(false, 'Failed to send the message.');
formError();
}).always(function () {
loader.hide();
button.prop('disabled', false);
});
}
if (window.grecaptcha && reCaptchaSiteKey) {
grecaptcha.ready(function () {
grecaptcha.execute(reCaptchaSiteKey, { action: 'contact' }).then(postContact);
});
} else {
postContact('');
}
});
$('#cvFile').on('change', function () {
var file = this.files && this.files[0];
$('#cvFileName').text(file ? file.name : 'PDF only, max size handled by backend');
});
$('#cvMatcherForm').on('submit', async function (event) {
event.preventDefault();
var file = $('#cvFile')[0] && $('#cvFile')[0].files[0];
var jobUrl = $('#jobUrl').val();
var jobDescription = $('#jobDescription').val();
var consent = $('#gdprConsent').is(':checked');
var $msg = $('#matcherMsg');
var $button = $('#matchSubmit');
var $result = $('#matchResult');
if (!file) { $msg.removeClass().addClass('form-message text-danger').text('Please upload a CV PDF.'); return; }
if (!jobUrl && !jobDescription) { $msg.removeClass().addClass('form-message text-danger').text('Add a job link or paste a job description.'); return; }
if (!consent) { $msg.removeClass().addClass('form-message text-danger').text('GDPR consent is required.'); return; }
$button.prop('disabled', true).text('Processing...');
$msg.removeClass().addClass('form-message').text('Extracting CV and matching job...');
$result.html('<div class="empty-result">Processing CV PDF and job input. Backend endpoints must be available.</div>');
try {
var formData = new FormData();
formData.append('cv', file);
formData.append('gdprConsent', String(consent));
var cvResponse = await fetch('/api/rag/cv', { method: 'POST', body: formData });
if (!cvResponse.ok) throw new Error('CV extraction failed');
var cvData = await cvResponse.json();
var matchResponse = await fetch('/api/rag/match-job', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cvDocumentId: cvData.documentId || cvData.cvDocumentId,
jobUrl: jobUrl,
jobDescription: jobDescription,
gdprConsent: consent
})
});
if (!matchResponse.ok) throw new Error('Job matching failed');
var match = await matchResponse.json();
renderMatchResult(match);
$msg.removeClass().addClass('form-message text-success').text('Match completed.');
} catch (err) {
console.error(err);
$msg.removeClass().addClass('form-message text-danger').text(err.message || 'Failed to run the matcher.');
$result.html('<div class="empty-result">The frontend is ready, but the backend endpoints /api/rag/cv and /api/rag/match-job must be implemented.</div>');
} finally {
$button.prop('disabled', false).text('Extract CV and match job');
}
});
function renderMatchResult(match) {
var score = match.score || match.matchScore || 0;
var summary = match.summary || 'No summary returned.';
var strengths = match.strengths || [];
var gaps = match.gaps || match.missingSkills || [];
var evidence = match.evidence || match.retrievedChunks || [];
function list(items) {
if (!items || !items.length) return '<p class="empty-result">No items returned.</p>';
return '<ul class="result-list">' + items.map(function (x) {
var text = typeof x === 'string' ? x : (x.text || x.title || JSON.stringify(x));
return '<li>' + escapeHtml(text) + '</li>';
}).join('') + '</ul>';
}
$('#matchResult').html(
'<div class="score-badge">' + Number(score).toFixed(0) + '%</div>' +
'<p>' + escapeHtml(summary) + '</p>' +
'<h3>Strengths</h3>' + list(strengths) +
'<h3>Gaps</h3>' + list(gaps) +
'<h3>Retrieved CV evidence</h3>' + list(evidence)
);
}
function escapeHtml(value) {
return String(value).replace(/[&<>'"]/g, function (char) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[char];
});
}
$(window).on('load', function () {
$.when(getRecaptchaWebKey(), getGoogleTagManagerId()).always(initConsent);
});
})(jQuery);
File diff suppressed because one or more lines are too long
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cookies Policy - easySoft</title>
<meta name="description" content="Information about how cookies are used on the easySoft website and how browser preferences can be managed.">
<link rel="stylesheet" href="css/legal.css">
</head>
<body>
<div class="wrap">
<div class="topbar">
<a class="brand" href="\" aria-label="easySoft home">
<div class="brand-badge">
<img src="\logo.png" alt="easySoft">
</div>
<div class="brand-copy">easySoft<small>Legal pages</small></div>
</a>
<div class="switcher">
<a href="cookies-ro.html" class="lang-link " data-lang="ro" aria-label="Română">
<img src="img/flags/ro.svg" alt="Română">
</a>
<a href="cookies-en.html" class="lang-link active" data-lang="en" aria-label="English">
<img src="img/flags/en.svg" alt="English">
</a>
</div>
</div>
<div class="hero">
<div class="kicker">Cookies</div>
<h1>Cookies Policy</h1>
<p>Information about how cookies are used on the easySoft website and how browser preferences can be managed.</p>
</div>
<div class="content">
<div class="notice"><strong>Cookies Policy</strong> explains how easySoft uses cookies for website operation, analytics, and improving user experience.</div>
<h2>1. What cookies are</h2>
<p>Cookies are small files stored on your device when you visit a website. They help the site function correctly and remember certain preferences.</p>
<h2>2. Types of cookies we may use</h2>
<ul>
<li><strong>Essential cookies</strong> required for the basic operation of the website</li>
<li><strong>Analytics cookies</strong> used to understand how the website is used</li>
<li><strong>Preference cookies</strong> used to remember language or other selected options</li>
<li><strong>Marketing cookies</strong> only if explicitly enabled and used</li>
</ul>
<h2>3. Why we use cookies</h2>
<p>We use cookies for:</p>
<ul>
<li>ensuring technical operation of the website</li>
<li>improving performance and usability</li>
<li>remembering visitor settings</li>
<li>aggregated statistical analysis</li>
</ul>
<h2>4. Managing cookies</h2>
<p>You can control or delete cookies from your browser settings. You can also block certain categories of cookies, but doing so may affect some parts of the website.</p>
<h2>5. Third-party cookies</h2>
<p>Some services embedded on the website may set their own cookies, for example analytics services or external content. Those cookies are governed by the respective providers policies.</p>
<h2>6. Updates</h2>
<p>This policy may be updated periodically. The current version will remain published on the website.</p>
<p class="meta">Last updated: 2026-03-26</p>
</div>
<div class="footer">
<small>©2026 easySoft. All rights reserved.</small>
<div class="footer-links">
<a href="terms-en.html">Terms and Conditions</a>
<a href="privacy-en.html">Privacy Policy</a>
<a href="cookies-en.html">Cookies Policy</a>
</div>
</div>
</div>
<script src="js/legal.js"></script>
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Politica de COOKIES - easySoft</title>
<meta name="description" content="Informații despre utilizarea cookies pe site-ul easySoft și despre gestionarea preferințelor browserului.">
<link rel="stylesheet" href="css/legal.css">
</head>
<body>
<div class="wrap">
<div class="topbar">
<a class="brand" href="\" aria-label="easySoft home">
<div class="brand-badge">
<img src="\logo.png" alt="easySoft">
</div>
<div class="brand-copy">easySoft<small>Pagini legale</small></div>
</a>
<div class="switcher">
<a href="cookies-ro.html" class="lang-link active" data-lang="ro" aria-label="Română">
<img src="img/flags/ro.svg" alt="Română">
</a>
<a href="cookies-en.html" class="lang-link " data-lang="en" aria-label="English">
<img src="img/flags/en.svg" alt="English">
</a>
</div>
</div>
<div class="hero">
<div class="kicker">Cookies</div>
<h1>Politica de COOKIES</h1>
<p>Informații despre utilizarea cookies pe site-ul easySoft și despre gestionarea preferințelor browserului.</p>
</div>
<div class="content">
<div class="notice"><strong>Politica de COOKIES</strong> explică modul în care easySoft utilizează fișierele cookie pentru funcționarea site-ului, analiză și îmbunătățirea experienței utilizatorilor.</div>
<h2>1. Ce sunt cookies</h2>
<p>Cookies sunt fișiere mici stocate pe dispozitivul dvs. atunci când vizitați un site. Acestea ajută la funcționarea corectă a site-ului și la memorarea anumitor preferințe.</p>
<h2>2. Ce tipuri de cookies putem folosi</h2>
<ul>
<li><strong>Cookies esențiale</strong> necesare pentru funcționarea de bază a site-ului</li>
<li><strong>Cookies de analiză</strong> pentru a înțelege modul în care este utilizat site-ul</li>
<li><strong>Cookies de preferințe</strong> pentru memorarea limbii sau a altor opțiuni selectate</li>
<li><strong>Cookies de marketing</strong> doar dacă sunt activate și utilizate în mod expres</li>
</ul>
<h2>3. Scopul utilizării</h2>
<p>Folosim cookies pentru:</p>
<ul>
<li>asigurarea funcționării tehnice a site-ului</li>
<li>îmbunătățirea performanței și utilizabilității</li>
<li>memorarea setărilor vizitatorilor</li>
<li>analiză statistică agregată</li>
</ul>
<h2>4. Gestionarea cookies</h2>
<p>Puteți controla sau șterge cookies din setările browserului. De asemenea, puteți bloca anumite categorii de cookies, însă acest lucru poate afecta funcționarea unor secțiuni ale site-ului.</p>
<h2>5. Cookies terțe părți</h2>
<p>Unele servicii integrate în site pot seta propriile cookies, de exemplu servicii de analiză sau conținut extern. Acestea sunt guvernate de politicile respectivilor furnizori.</p>
<h2>6. Actualizări</h2>
<p>Această politică poate fi actualizată periodic. Versiunea curentă va fi publicată permanent pe site.</p>
<p class="meta">Ultima actualizare: 2026-03-26</p>
</div>
<div class="footer">
<small>©2026 easySoft. Toate drepturile sunt rezervate.</small>
<div class="footer-links">
<a href="terms-ro.html">Termeni și condiții</a>
<a href="privacy-ro.html">Politica de confidențialitate</a>
<a href="cookies-ro.html">Politica de COOKIES</a>
</div>
</div>
</div>
<script src="js/legal.js"></script>
</body>
</html>
+86
View File
@@ -0,0 +1,86 @@
:root{
--bg:#07192f;
--bg2:#0a2441;
--card:#0e1f37;
--text:#eaf2ff;
--muted:#b4c5dd;
--line:rgba(255,255,255,.10);
--accent:#7eb7ff;
}
*{box-sizing:border-box}
body{
margin:0;
font-family:Arial,Helvetica,sans-serif;
background:
radial-gradient(circle at top left, rgba(79,140,255,.18), transparent 28%),
linear-gradient(180deg, var(--bg) 0%, #05111f 100%);
color:var(--text);
line-height:1.75;
}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:none}
.wrap{max-width:1100px;margin:0 auto;padding:28px 20px 60px}
.topbar{
display:flex;justify-content:space-between;align-items:center;gap:16px;
padding:10px 0 22px;margin-bottom:24px;border-bottom:1px solid var(--line);
}
.brand{display:flex;align-items:center;gap:14px;}
.brand-badge {
width: 48px;
height: 48px;
background: none;
}
.brand-badge img {
width: 100%;
height: 100%;
object-fit: contain;
}
.brand-copy small{display:block;color:var(--muted);font-weight:400}
.switcher{display:flex;align-items:center;gap:10px}
.switcher a{
display:inline-flex;align-items:center;justify-content:center;
width:44px;height:44px;border-radius:999px;border:1px solid var(--line);
background:rgba(255,255,255,.04);transition:.2s ease;
}
.switcher a:hover{text-decoration:none;transform:translateY(-1px)}
.switcher a.active{border-color:rgba(126,183,255,.65);box-shadow:0 0 0 3px rgba(126,183,255,.13)}
.switcher img{width:24px;height:24px;display:block}
.hero,.content,.footer{
background:linear-gradient(180deg, rgba(255,255,255,.03), rgba(255,255,255,.02));
border:1px solid var(--line);
border-radius:22px;
box-shadow:0 25px 60px rgba(0,0,0,.18);
}
.hero{padding:28px 30px;margin-bottom:18px}
.kicker{
display:inline-block;padding:8px 12px;border-radius:999px;
background:rgba(255,255,255,.04);border:1px solid var(--line);color:var(--muted);
font-size:13px;margin-bottom:12px
}
.hero h1{margin:0 0 8px;font-size:40px;line-height:1.08}
.hero p{margin:0;color:var(--muted);max-width:780px}
.content{padding:30px}
.content h2{font-size:28px;line-height:1.15;margin:28px 0 10px}
.content h2:first-child{margin-top:0}
.content p{margin:0 0 14px;color:#deebff}
.content ul{margin:0 0 18px 22px;padding:0}
.content li{margin-bottom:8px;color:#deebff}
.notice{
margin:16px 0 18px;padding:16px 18px;border-radius:18px;
background:rgba(126,183,255,.08);border:1px solid rgba(126,183,255,.18)
}
.footer{
margin-top:18px;padding:22px 26px;display:flex;justify-content:space-between;gap:16px;align-items:center;flex-wrap:wrap
}
.footer-links{display:flex;gap:18px;flex-wrap:wrap}
.footer small{color:var(--muted)}
.meta{color:var(--muted);font-size:14px}
@media (max-width:768px){
.hero h1{font-size:32px}
.content{padding:22px}
.content h2{font-size:24px}
.topbar{align-items:flex-start;flex-direction:column}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 30"><clipPath id="a"><path d="M0,0 v30 h60 v-30 z"/></clipPath><clipPath id="b"><path d="M30,15 h30 v15 z v15 h-30 z h-30 v-15 z v-15 h30 z"/></clipPath><g clip-path="url(#a)"><path d="M0,0 v30 h60 v-30 z" fill="#012169"/><path d="M0,0 60,30 M60,0 0,30" stroke="#fff" stroke-width="6"/><path d="M0,0 60,30 M60,0 0,30" clip-path="url(#b)" stroke="#C8102E" stroke-width="4"/><path d="M30,0 v30 M0,15 h60" stroke="#fff" stroke-width="10"/><path d="M30,0 v30 M0,15 h60" stroke="#C8102E" stroke-width="6"/></g></svg>

After

Width:  |  Height:  |  Size: 567 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="1" height="2" fill="#002B7F"/><rect x="1" width="1" height="2" fill="#FCD116"/><rect x="2" width="1" height="2" fill="#CE1126"/></svg>

After

Width:  |  Height:  |  Size: 205 B

+23
View File
@@ -0,0 +1,23 @@
(function(){
function browserLang(){
var lang = (navigator.language || navigator.userLanguage || 'en').toLowerCase();
return lang.indexOf('ro') === 0 ? 'ro' : 'en';
}
function targetPage(current, lang){
if(current.indexOf('-ro.html') !== -1) return current.replace('-ro.html', '-' + lang + '.html');
if(current.indexOf('-en.html') !== -1) return current.replace('-en.html', '-' + lang + '.html');
return current;
}
var links = document.querySelectorAll('.lang-link');
for(var i=0;i<links.length;i++){
links[i].addEventListener('click', function(e){
e.preventDefault();
localStorage.setItem('legalLang', this.getAttribute('data-lang'));
window.location.href = targetPage(window.location.pathname, this.getAttribute('data-lang'));
});
}
if(!localStorage.getItem('legalLang')){
localStorage.setItem('legalLang', browserLang());
}
})();
+123
View File
@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy - easySoft</title>
<meta name="description" content="Information about the collection, use, and protection of personal data by easySoft.">
<link rel="stylesheet" href="css/legal.css">
</head>
<body>
<div class="wrap">
<div class="topbar">
<a class="brand" href="\" aria-label="easySoft home">
<div class="brand-badge">
<img src="\logo.png" alt="easySoft">
</div>
<div class="brand-copy">easySoft<small>Legal pages</small></div>
</a>
<div class="switcher">
<a href="privacy-ro.html" class="lang-link " data-lang="ro" aria-label="Română">
<img src="img/flags/ro.svg" alt="Română">
</a>
<a href="privacy-en.html" class="lang-link active" data-lang="en" aria-label="English">
<img src="img/flags/en.svg" alt="English">
</a>
</div>
</div>
<div class="hero">
<div class="kicker">Data protection</div>
<h1>Privacy Policy</h1>
<p>Information about the collection, use, and protection of personal data by easySoft.</p>
</div>
<div class="content">
<div class="notice"><strong>easySoft</strong> respects the confidentiality of personal data and is committed to protecting the information of clients, partners, and website visitors.</div>
<h2>1. Who we are</h2>
<p>easySoft</p>
<p>Email: <a href="mailto:contact@easysoft.ro">contact@easysoft.ro</a><br>Website: <a href="https://easysoft.ro">easysoft.ro</a></p>
<p>This document explains how we collect, use, and protect your personal data.</p>
<h2>2. What data we collect</h2>
<p>We may collect the following types of data:</p>
<ul>
<li>Contact details such as name, email address, and phone number</li>
<li>Data submitted voluntarily through forms, including messages, requests, and quote inquiries</li>
<li>Technical data such as IP address, browser type, cookies, and visited pages</li>
<li>Data needed in the contractual relationship, if you are a client</li>
</ul>
<h2>3. Purpose of processing</h2>
<p>Your data may be processed for:</p>
<ul>
<li>answering requests and direct communication</li>
<li>providing our services</li>
<li>improving website functionality</li>
<li>marketing, only with consent</li>
<li>meeting legal and accounting obligations</li>
</ul>
<h2>4. Legal basis</h2>
<p>We process data under Article 6 GDPR based on:</p>
<ul>
<li>the data subjects consent</li>
<li>performance of a contract</li>
<li>legal obligation</li>
<li>legitimate interest</li>
</ul>
<h2>5. Data retention</h2>
<p>We keep personal data only for as long as necessary for the purposes for which it was collected or as required by law.</p>
<h2>6. Data disclosure</h2>
<p>Data may be disclosed only to:</p>
<ul>
<li>IT or hosting providers</li>
<li>contractual partners involved in service delivery</li>
<li>public authorities, only where required by law</li>
</ul>
<p>We do not sell or transfer data to third parties for commercial purposes.</p>
<h2>7. Your rights under GDPR</h2>
<p>You have the right to:</p>
<ul>
<li>access</li>
<li>rectification</li>
<li>erasure (“right to be forgotten”)</li>
<li>restriction</li>
<li>objection</li>
<li>data portability</li>
<li>withdrawal of consent</li>
</ul>
<p>Requests can be sent to: <a href="mailto:contact@easysoft.ro">contact@easysoft.ro</a>.</p>
<h2>8. Data security</h2>
<p>We implement appropriate technical and organizational measures to protect data against unauthorized access, loss, or disclosure.</p>
<h2>9. Transfers outside the EU</h2>
<p>We normally do not transfer data outside the EU. If this becomes necessary, appropriate safeguards will be used, including Standard Contractual Clauses.</p>
<h2>10. Policy updates</h2>
<p>We reserve the right to update this policy. The latest version will remain permanently available on the website.</p>
<p class="meta">Last updated: 2026-03-26</p>
</div>
<div class="footer">
<small>©2026 easySoft. All rights reserved.</small>
<div class="footer-links">
<a href="terms-en.html">Terms and Conditions</a>
<a href="privacy-en.html">Privacy Policy</a>
<a href="cookies-en.html">Cookies Policy</a>
</div>
</div>
</div>
<script src="js/legal.js"></script>
</body>
</html>
+123
View File
@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Politica de confidențialitate - easySoft</title>
<meta name="description" content="Informații despre colectarea, utilizarea și protejarea datelor cu caracter personal de către easySoft.">
<link rel="stylesheet" href="css/legal.css">
</head>
<body>
<div class="wrap">
<div class="topbar">
<a class="brand" href="\" aria-label="easySoft home">
<div class="brand-badge">
<img src="\logo.png" alt="easySoft">
</div>
<div class="brand-copy">easySoft<small>Pagini legale</small></div>
</a>
<div class="switcher">
<a href="privacy-ro.html" class="lang-link active" data-lang="ro" aria-label="Română">
<img src="img/flags/ro.svg" alt="Română">
</a>
<a href="privacy-en.html" class="lang-link " data-lang="en" aria-label="English">
<img src="img/flags/en.svg" alt="English">
</a>
</div>
</div>
<div class="hero">
<div class="kicker">Protecția datelor</div>
<h1>Politica de confidențialitate</h1>
<p>Informații despre colectarea, utilizarea și protejarea datelor cu caracter personal de către easySoft.</p>
</div>
<div class="content">
<div class="notice"><strong>easySoft</strong> respectă confidențialitatea datelor cu caracter personal și se angajează să protejeze informațiile clienților, partenerilor și vizitatorilor site-ului nostru.</div>
<h2>1. Cine suntem</h2>
<p>easySoft</p>
<p>Email: <a href="mailto:contact@easysoft.ro">contact@easysoft.ro</a><br>Website: <a href="https://easysoft.ro">easysoft.ro</a></p>
<p>Acest document explică modul în care colectăm, utilizăm și protejăm datele dvs. personale.</p>
<h2>2. Ce date colectăm</h2>
<p>Putem colecta următoarele tipuri de date:</p>
<ul>
<li>Date de contact (nume, email, telefon)</li>
<li>Date furnizate voluntar prin formulare (mesaje, solicitări, cereri de ofertă)</li>
<li>Date tehnice (IP, browser, cookies, pagini accesate)</li>
<li>Date necesare în relația contractuală (doar dacă sunteți client)</li>
</ul>
<h2>3. Scopurile procesării</h2>
<p>Datele sunt prelucrate pentru:</p>
<ul>
<li>răspuns la solicitări și comunicare directă</li>
<li>furnizarea serviciilor noastre</li>
<li>îmbunătățirea funcționalității site-ului</li>
<li>marketing (numai cu acord)</li>
<li>respectarea obligațiilor legale și contabile</li>
</ul>
<h2>4. Temeiul legal</h2>
<p>Procesăm datele conform art. 6 GDPR:</p>
<ul>
<li>consimțământul persoanei vizate</li>
<li>executarea unui contract</li>
<li>obligație legală</li>
<li>interes legitim</li>
</ul>
<h2>5. Stocarea datelor</h2>
<p>Păstrăm datele doar atât timp cât este necesar pentru scopurile pentru care au fost colectate sau conform obligațiilor legale.</p>
<h2>6. Dezvăluirea datelor</h2>
<p>Datele pot fi transmise doar către:</p>
<ul>
<li>furnizori IT sau hosting</li>
<li>parteneri contractuali implicați în furnizarea serviciilor</li>
<li>autorități, numai dacă legea o impune</li>
</ul>
<p>Nu vindem și nu înstrăinăm datele către terți în scopuri comerciale.</p>
<h2>7. Drepturile dvs. (GDPR)</h2>
<p>Aveți dreptul:</p>
<ul>
<li>de acces</li>
<li>rectificare</li>
<li>ștergere (“dreptul de a fi uitat”)</li>
<li>restricționare</li>
<li>opoziție</li>
<li>portabilitate</li>
<li>retragerea consimțământului</li>
</ul>
<p>Solicitările se trimit la: <a href="mailto:contact@easysoft.ro">contact@easysoft.ro</a>.</p>
<h2>8. Securitatea datelor</h2>
<p>Implementăm măsuri tehnice și organizatorice adecvate pentru protejarea datelor împotriva accesului neautorizat, pierderii sau divulgării.</p>
<h2>9. Transferul datelor în afara UE</h2>
<p>În mod normal nu transferăm date în afara UE. Dacă acest lucru devine necesar, vor fi folosite garanții adecvate (Clauze Contractuale Standard).</p>
<h2>10. Modificări ale politicii</h2>
<p>Ne rezervăm dreptul de a actualiza această politică. Ultima versiune va fi afișată permanent pe site.</p>
<p class="meta">Ultima actualizare: 2026-03-26</p>
</div>
<div class="footer">
<small>©2026 easySoft. Toate drepturile sunt rezervate.</small>
<div class="footer-links">
<a href="terms-ro.html">Termeni și condiții</a>
<a href="privacy-ro.html">Politica de confidențialitate</a>
<a href="cookies-ro.html">Politica de COOKIES</a>
</div>
</div>
</div>
<script src="js/legal.js"></script>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terms and Conditions - easySoft</title>
<meta name="description" content="Rules for using the easySoft website and the general conditions applicable to the published content and materials.">
<link rel="stylesheet" href="css/legal.css">
</head>
<body>
<div class="wrap">
<div class="topbar">
<a class="brand" href="\" aria-label="easySoft home">
<div class="brand-badge">
<img src="\logo.png" alt="easySoft">
</div>
<div class="brand-copy">easySoft<small>Legal pages</small></div>
</a>
<div class="switcher">
<a href="terms-ro.html" class="lang-link " data-lang="ro" aria-label="Română">
<img src="img/flags/ro.svg" alt="Română">
</a>
<a href="terms-en.html" class="lang-link active" data-lang="en" aria-label="English">
<img src="img/flags/en.svg" alt="English">
</a>
</div>
</div>
<div class="hero">
<div class="kicker">Terms of use</div>
<h1>Terms and Conditions</h1>
<p>Rules for using the easySoft website and the general conditions applicable to the published content and materials.</p>
</div>
<div class="content">
<div class="notice"><strong>Terms and Conditions</strong> govern the use of the easySoft website and the way users may access the information and materials published on it.</div>
<h2>1. General information</h2>
<p>The easySoft website is operated to present the software products and services offered, including applications for dental offices, inventory management, and backup.</p>
<h2>2. Acceptance of terms</h2>
<p>By accessing and using this website, you accept these terms of use. If you do not agree, please do not use the website.</p>
<h2>3. Use of content</h2>
<p>The website content is provided for informational and commercial purposes only. Copying, distributing, modifying, or republishing content without prior approval is not permitted.</p>
<h2>4. Intellectual property</h2>
<p>Texts, images, graphic elements, logos, and the website structure belong to easySoft or are used under lawful rights. All rights are reserved.</p>
<h2>5. Limitation of liability</h2>
<p>We make efforts to keep information accurate and up to date, but we do not guarantee that the website is free of errors or interruptions. Use of the information is at your own risk.</p>
<h2>6. External links</h2>
<p>The website may contain links to third-party websites. We are not responsible for the content or policies of those external websites.</p>
<h2>7. Data protection</h2>
<p>Processing of personal data is described in the <a href="privacy-en.html">Privacy Policy</a>, and the use of cookies is explained in the <a href="cookies-en.html">Cookies Policy</a>.</p>
<h2>8. Changes</h2>
<p>We reserve the right to modify these terms at any time. The updated version will be published on the website.</p>
<h2>9. Governing law</h2>
<p>These terms are governed by the laws of Romania. Any disputes will be settled by the competent courts in Romania.</p>
<p class="meta">Last updated: 2026-03-26</p>
</div>
<div class="footer">
<small>©2026 easySoft. All rights reserved.</small>
<div class="footer-links">
<a href="terms-en.html">Terms and Conditions</a>
<a href="privacy-en.html">Privacy Policy</a>
<a href="cookies-en.html">Cookies Policy</a>
</div>
</div>
</div>
<script src="js/legal.js"></script>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Termeni și condiții - easySoft</title>
<meta name="description" content="Regulile de utilizare a site-ului easySoft și condițiile generale aplicabile conținutului și materialelor publicate.">
<link rel="stylesheet" href="css/legal.css">
</head>
<body>
<div class="wrap">
<div class="topbar">
<a class="brand" href="\" aria-label="easySoft home">
<div class="brand-badge">
<img src="\logo.png" alt="easySoft">
</div>
<div class="brand-copy">easySoft<small>Pagini legale</small></div>
</a>
<div class="switcher">
<a href="terms-ro.html" class="lang-link active" data-lang="ro" aria-label="Română">
<img src="img/flags/ro.svg" alt="Română">
</a>
<a href="terms-en.html" class="lang-link " data-lang="en" aria-label="English">
<img src="img/flags/en.svg" alt="English">
</a>
</div>
</div>
<div class="hero">
<div class="kicker">Condiții de utilizare</div>
<h1>Termeni și condiții</h1>
<p>Regulile de utilizare a site-ului easySoft și condițiile generale aplicabile conținutului și materialelor publicate.</p>
</div>
<div class="content">
<div class="notice"><strong>Termenii și condițiile</strong> reglementează utilizarea site-ului easySoft și modul în care utilizatorii pot accesa informațiile și materialele publicate.</div>
<h2>1. Informații generale</h2>
<p>Site-ul easySoft este operat pentru prezentarea produselor și serviciilor software oferite, inclusiv aplicații pentru cabinete dentare, gestiune și backup.</p>
<h2>2. Acceptarea termenilor</h2>
<p>Prin accesarea și utilizarea acestui site, acceptați prezentele condiții de utilizare. Dacă nu sunteți de acord cu acestea, vă rugăm să nu utilizați site-ul.</p>
<h2>3. Utilizarea conținutului</h2>
<p>Conținutul site-ului este furnizat exclusiv în scop informativ și comercial. Nu este permisă copierea, distribuirea, modificarea sau republicarea conținutului fără acordul prealabil al operatorului site-ului.</p>
<h2>4. Proprietate intelectuală</h2>
<p>Textele, imaginile, elementele grafice, logo-urile și structura site-ului aparțin easySoft sau sunt utilizate în baza unor drepturi legale. Toate drepturile sunt rezervate.</p>
<h2>5. Limitarea răspunderii</h2>
<p>Depunem eforturi pentru a menține informațiile corecte și actualizate, însă nu garantăm că site-ul este lipsit de erori sau întreruperi. Utilizarea informațiilor se face pe propria răspundere.</p>
<h2>6. Linkuri externe</h2>
<p>Site-ul poate conține linkuri către site-uri terțe. Nu suntem responsabili pentru conținutul sau politicile acestor site-uri externe.</p>
<h2>7. Protecția datelor</h2>
<p>Prelucrarea datelor cu caracter personal este descrisă în <a href="privacy-ro.html">Politica de confidențialitate</a>, iar utilizarea cookies este explicată în <a href="cookies-ro.html">Politica de COOKIES</a>.</p>
<h2>8. Modificări</h2>
<p>Ne rezervăm dreptul de a modifica acești termeni în orice moment. Versiunea actualizată va fi publicată pe site.</p>
<h2>9. Legea aplicabilă</h2>
<p>Acești termeni sunt guvernați de legislația din România. Eventualele litigii vor fi soluționate de instanțele competente din România.</p>
<p class="meta">Ultima actualizare: 2026-03-26</p>
</div>
<div class="footer">
<small>©2026 easySoft. Toate drepturile sunt rezervate.</small>
<div class="footer-links">
<a href="terms-ro.html">Termeni și condiții</a>
<a href="privacy-ro.html">Politica de confidențialitate</a>
<a href="cookies-ro.html">Politica de COOKIES</a>
</div>
</div>
</div>
<script src="js/legal.js"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bgGrad" x1="180" y1="120" x2="860" y2="900" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FF7A7A"/>
<stop offset="0.38" stop-color="#F04444"/>
<stop offset="1" stop-color="#9F1239"/>
</linearGradient>
<linearGradient id="glassGrad" x1="250" y1="160" x2="610" y2="430" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
<linearGradient id="shadowGrad" x1="280" y1="250" x2="760" y2="760" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#000000" stop-opacity="0"/>
<stop offset="1" stop-color="#4A0417" stop-opacity="0.32"/>
</linearGradient>
<linearGradient id="letterGrad" x1="310" y1="330" x2="760" y2="710" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#FDECEC"/>
</linearGradient>
<filter id="outerShadow" x="120" y="120" width="784" height="784" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="18" stdDeviation="28" flood-color="#4A0417" flood-opacity="0.28"/>
</filter>
<filter id="letterShadow" x="220" y="220" width="584" height="584" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#5A1022" flood-opacity="0.18"/>
</filter>
<clipPath id="rounded">
<rect x="170" y="150" width="684" height="684" rx="170"/>
</clipPath>
</defs>
<g filter="url(#outerShadow)">
<rect x="170" y="150" width="684" height="684" rx="170" fill="url(#bgGrad)"/>
<rect x="171.5" y="151.5" width="681" height="681" rx="168.5" stroke="rgba(255,255,255,0.14)" stroke-opacity="0.18" />
</g>
<g clip-path="url(#rounded)">
<path d="M215 250C300 175 425 150 555 150H854V834H765C558 834 365 756 250 620C158 510 140 354 215 250Z" fill="url(#shadowGrad)"/>
<path d="M230 208C315 148 430 130 560 130H800C725 232 605 300 465 300H220C217 267 219 236 230 208Z" fill="url(#glassGrad)"/>
</g>
<g filter="url(#letterShadow)">
<!-- Premium stylized E -->
<path d="M382 328H713C732 328 741 351 726 365L679 410C668 420 654 425 640 425H453L382 328Z" fill="url(#letterGrad)"/>
<path d="M331 430H657C676 430 685 452 671 467L626 512C615 523 601 529 586 529H403L331 430Z" fill="url(#letterGrad)"/>
<path d="M298 575H618C638 575 646 599 632 613L590 654C579 665 564 671 549 671H370L298 575Z" fill="url(#letterGrad)"/>
<!-- subtle base cuts -->
<path d="M382 328L453 425H415L344 425L382 328Z" fill="#F3DADA" fill-opacity="0.7"/>
<path d="M331 430L403 529H365L293 529L331 430Z" fill="#F3DADA" fill-opacity="0.7"/>
<path d="M298 575L370 671H332L260 671L298 575Z" fill="#F3DADA" fill-opacity="0.7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+784
View File
@@ -0,0 +1,784 @@
:root {
--bg: #041120;
--bg-soft: #0a1c34;
--panel: rgba(10, 22, 42, 0.82);
--panel-border: rgba(255, 255, 255, 0.1);
--text: #eaf1ff;
--muted: #9bb0d0;
--primary: #5fa0ff;
--primary-strong: #8b6cff;
--card-radius: 28px;
--shadow: 0 18px 60px rgba(0, 0, 0, 0.28);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: radial-gradient(circle at top left, #12345d 0%, #071326 35%, #030915 100%);
color: var(--text);
}
a {
color: inherit;
text-decoration: none;
}
img {
max-width: 100%;
display: block;
}
.container {
width: 100%;
max-width: 1120px;
margin: 0 auto;
padding-left: 20px;
padding-right: 20px;
}
.site-shell {
overflow: hidden;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.header {
position: sticky;
top: 0;
z-index: 20;
background: rgba(3, 11, 23, 0.76);
backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.nav-wrap {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-height: 84px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-mark {
width: 48px;
height: 48px;
background: none;
}
.brand-mark img {
width: 100%;
height: 100%;
object-fit: contain;
}
.brand-text {
display: block;
font-size: 1.7rem;
font-weight: 800;
}
.brand small {
display: block;
color: var(--muted);
margin-top: 2px;
}
.nav {
display: flex;
align-items: center;
gap: 32px;
}
.nav a {
color: #d7e3fb;
font-weight: 600;
}
.nav a:hover {
color: #ffffff;
}
.nav-actions {
display: flex;
align-items: center;
gap: 12px;
}
.lang-switch {
display: flex;
align-items: center;
gap: 10px;
padding: 6px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.lang-flag {
width: 48px;
height: 34px;
padding: 3px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
cursor: pointer;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease, opacity 0.2s ease;
opacity: 0.82;
}
.lang-flag img {
width: 100%;
height: 100%;
display: block;
border-radius: 8px;
object-fit: cover;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.18);
}
.lang-flag:hover {
opacity: 1;
transform: translateY(-1px);
}
.lang-flag[aria-pressed="true"] {
opacity: 1;
border-color: rgba(95, 160, 255, 0.65);
background: rgba(95, 160, 255, 0.08);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 0 0 1px rgba(95, 160, 255, 0.12);
}
.menu-toggle {
display: none;
background: transparent;
border: 0;
padding: 8px;
}
.menu-toggle span {
display: block;
width: 24px;
height: 2px;
background: #ffffff;
margin: 5px 0;
}
.hero {
padding: 72px 0 48px;
}
.hero-grid {
display: grid;
grid-template-columns: 1.05fr 0.95fr;
gap: 42px;
align-items: center;
}
.eyebrow {
display: inline-flex;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
border-radius: 999px;
padding: 8px 14px;
color: #bfd1f0;
font-size: 0.92rem;
margin-bottom: 18px;
}
.hero h1 {
font-size: clamp(2.8rem, 6vw, 5.8rem);
line-height: 0.95;
margin: 0 0 20px;
letter-spacing: -0.06em;
}
.hero-text {
font-size: 1.14rem;
line-height: 1.7;
color: var(--muted);
max-width: 700px;
}
.hero-actions,
.product-actions {
display: flex;
gap: 14px;
flex-wrap: wrap;
margin-top: 28px;
}
.btn {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 999px;
padding: 14px 22px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
transition: 0.2s ease;
}
.btn:hover {
transform: translateY(-1px);
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), #7ac4ff);
color: #071326;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.product-actions .btn {
min-width: 150px;
}
.hero-metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin-top: 28px;
}
.hero-metrics div,
.hero-badge,
.service-card,
.about-panel,
.contact-form,
.contact-list > div {
background: var(--panel);
border: 1px solid var(--panel-border);
box-shadow: var(--shadow);
}
.hero-metrics div {
border-radius: 22px;
padding: 18px;
}
.hero-metrics strong {
display: block;
font-size: 1.7rem;
margin-bottom: 8px;
}
.hero-metrics span {
color: var(--muted);
}
.hero-card-inner {
position: relative;
min-height: 640px;
border-radius: 34px;
padding: 28px;
background: linear-gradient(180deg, rgba(18, 35, 66, 0.95), rgba(10, 20, 39, 0.9));
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: var(--shadow);
}
.hero-card-label {
margin: 0 0 14px;
color: #d8e5ff;
font-weight: 600;
}
.hero-main-shot {
width: 78%;
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.14);
margin-top: 38px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.36);
}
.hero-floating {
position: absolute;
width: 38%;
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.14);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.36);
background: #d9e6ff;
}
.hero-floating-top {
top: 72px;
right: -10px;
transform: rotate(9deg);
}
.hero-floating-bottom {
right: 10px;
bottom: 90px;
transform: rotate(-8deg);
}
.hero-badges {
position: absolute;
left: 18px;
right: 18px;
bottom: 18px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.hero-badge {
border-radius: 20px;
padding: 18px;
}
.hero-badge strong {
display: block;
margin-bottom: 6px;
}
.hero-badge span {
color: var(--muted);
font-size: 0.96rem;
}
.section {
padding: 42px 0 28px;
}
.section-heading {
max-width: 760px;
margin-bottom: 26px;
}
.section-heading h2 {
font-size: clamp(2rem, 3.6vw, 3.2rem);
margin: 0 0 14px;
letter-spacing: -0.04em;
}
.section-heading p {
color: var(--muted);
line-height: 1.7;
}
.product-stack {
display: grid;
gap: 28px;
}
.product-card {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
align-items: center;
background: rgba(7, 18, 36, 0.66);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--card-radius);
padding: 24px;
}
.product-card.reverse .product-carousel {
order: 2;
}
.product-tag {
display: inline-flex;
padding: 8px 12px;
border-radius: 999px;
background: rgba(95, 160, 255, 0.12);
color: #cfe0ff;
margin-bottom: 14px;
}
.product-body h3 {
font-size: 2rem;
margin: 0 0 14px;
}
.product-body p,
.product-body li,
.service-card p,
.about-grid p,
.contact-grid p {
color: var(--muted);
line-height: 1.72;
}
.product-body ul {
padding-left: 20px;
margin: 18px 0 0;
}
.product-carousel {
position: relative;
overflow: hidden;
border-radius: 24px;
min-height: 380px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(255, 255, 255, 0.08);
}
.carousel-track {
position: relative;
height: 100%;
min-height: 380px;
}
.carousel-slide {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.35s ease;
}
.carousel-slide.active {
opacity: 1;
}
.carousel-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(4, 17, 32, 0.68);
color: #ffffff;
font-size: 1.6rem;
cursor: pointer;
}
.carousel-btn.prev {
left: 14px;
}
.carousel-btn.next {
right: 14px;
}
.carousel-dots {
position: absolute;
left: 0;
right: 0;
bottom: 16px;
z-index: 2;
display: flex;
justify-content: center;
gap: 8px;
}
.carousel-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.34);
border: 0;
padding: 0;
cursor: pointer;
}
.carousel-dot.active {
background: #ffffff;
}
.service-grid,
.contact-grid,
.about-grid {
display: grid;
gap: 22px;
}
.service-grid {
grid-template-columns: repeat(3, 1fr);
}
.service-card {
padding: 24px;
border-radius: 24px;
}
.about-grid {
grid-template-columns: 1.1fr 0.9fr;
align-items: start;
}
.about-panel {
border-radius: 28px;
padding: 24px;
display: grid;
gap: 20px;
}
.about-panel strong {
display: block;
margin-bottom: 6px;
}
.contact-grid {
grid-template-columns: 0.95fr 1.05fr;
align-items: start;
}
.contact-list {
display: grid;
gap: 14px;
margin-top: 22px;
}
.contact-list > div {
border-radius: 20px;
padding: 18px;
}
.contact-list span {
display: block;
color: #bfd1f0;
margin-bottom: 6px;
font-size: 0.94rem;
}
.contact-form {
border-radius: 28px;
padding: 24px;
display: grid;
gap: 16px;
}
.contact-form label span {
display: block;
margin-bottom: 8px;
font-weight: 600;
}
.contact-form input,
.contact-form textarea {
width: 100%;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: #0a1629;
color: #ffffff;
padding: 14px 16px;
font: inherit;
}
/* Footer */
.footer {
padding: 28px 0 44px;
}
.footer-wrap {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 24px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 24px;
}
.footer-wrap p {
margin: 0;
justify-self: center;
}
.back-to-top {
color: var(--muted);
white-space: nowrap;
transition: color 0.2s ease;
}
.back-to-top:hover {
color: var(--text);
}
.footer-links,
.footer-legal {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
justify-content: flex-end;
}
.footer-links a,
.footer-legal a {
text-decoration: none;
white-space: nowrap;
color: var(--muted);
transition: color 0.2s ease;
}
.footer-links a:hover,
.footer-legal a:hover {
color: var(--text);
}
/*----------------------------------------*/
/* 31. Cookie consent CSS
/*----------------------------------------*/
.cookie-overlay {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 99999;
padding: 16px;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(2px);
display: none;
}
.cookie-box {
max-width: 1100px;
margin: 0 auto;
background: #212529;
color: #ffffff;
border-radius: 10px;
padding: 14px 16px;
display: flex;
gap: 14px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
}
.cookie-text a {
color: #ffffff;
text-decoration: underline;
}
.cookie-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.cookie-manage {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 99998;
}
@media (max-width: 980px) {
.hero-grid,
.product-card,
.about-grid,
.contact-grid,
.service-grid {
grid-template-columns: 1fr;
}
.product-card.reverse .product-carousel {
order: initial;
}
.hero-card-inner {
min-height: 560px;
}
}
@media (max-width: 760px) {
.lang-switch {
order: -1;
}
.nav {
position: absolute;
left: 16px;
right: 16px;
top: 84px;
display: none;
flex-direction: column;
align-items: flex-start;
gap: 14px;
padding: 18px;
border-radius: 20px;
background: #0a1629;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.nav.is-open {
display: flex;
}
.menu-toggle {
display: block;
}
.hero {
padding-top: 40px;
}
.hero h1 {
line-height: 1.02;
}
.hero-metrics,
.hero-badges {
grid-template-columns: 1fr;
}
.hero-card-inner {
min-height: 660px;
}
.hero-main-shot {
width: 100%;
margin-top: 56px;
}
.hero-floating {
width: 42%;
}
.hero-floating-top {
top: 88px;
}
.hero-floating-bottom {
bottom: 180px;
}
.footer-wrap {
grid-template-columns: 1fr;
align-items: flex-start;
}
.footer-wrap p {
justify-self: start;
}
.footer-links,
.footer-legal {
justify-content: flex-start;
}
}