Przeglądaj źródła

COde cleanup: TODO:

fix the cascade delete.
master
rodzic
commit
170b91dced

+ 0
- 1
SecureSharing.Business/Infrastructure/MapperProfile.cs Wyświetl plik

{ {
CreateMap<Message, MessageDto>().ReverseMap(); CreateMap<Message, MessageDto>().ReverseMap();
} }
} }

+ 17
- 16
SecureSharing.Business/Services/MessageService.cs Wyświetl plik

using System.Reflection.Metadata;
using AutoMapper;
using AutoMapper;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SecureSharing.Business.Dtos; using SecureSharing.Business.Dtos;
using SecureSharing.Business.Infrastructure; using SecureSharing.Business.Infrastructure;
var toReturn = result.Select(x => x.Code).ToList(); var toReturn = result.Select(x => x.Code).ToList();
_dbContext.RemoveRange(result); _dbContext.RemoveRange(result);
await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
return toReturn; return toReturn;
} }


await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
} }


private void DeleteFiles(Guid basePathGuid)
{
// TODO: this path needs to be extracted somehow.
var basePath = Path.Combine(@"D:\secure-sharing\SecureSharing\wwwroot\files", basePathGuid.ToString());
Directory.Delete(basePath, true);
}
public async Task<bool> Delete(int id) public async Task<bool> Delete(int id)
{ {
var messageDto = await GetById(id); var messageDto = await GetById(id);
if (messageDto is null)
if (messageDto is null)
return false; return false;


DeleteFiles(messageDto.Code); DeleteFiles(messageDto.Code);

_dbContext.Messages.Remove(_mapper.Map<Message>(messageDto)); _dbContext.Messages.Remove(_mapper.Map<Message>(messageDto));
try try
{ {


public async Task<MessageDto> GetById(int messageId) public async Task<MessageDto> GetById(int messageId)
{ {
var result = await _dbContext.Messages.Include(x=>x.FileNames).AsNoTracking().FirstOrDefaultAsync(x => x.Id == messageId);
var result = await _dbContext.Messages.Include(x => x.FileNames).AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == messageId);
var mappedResult = _mapper.Map<MessageDto>(result); var mappedResult = _mapper.Map<MessageDto>(result);
return mappedResult; return mappedResult;
} }


public async Task<MessageDto> GetByCode(Guid code) public async Task<MessageDto> GetByCode(Guid code)
{ {
var result = await _dbContext.Messages.Include(x=>x.FileNames).AsNoTracking().FirstOrDefaultAsync(x => x.Code == code.ToString());
var result = await _dbContext.Messages.Include(x => x.FileNames).AsNoTracking()
.FirstOrDefaultAsync(x => x.Code == code.ToString());
var mappedResult = _mapper.Map<MessageDto>(result); var mappedResult = _mapper.Map<MessageDto>(result);
return mappedResult; return mappedResult;
} }
public async Task Update(MessageDto messageDto) public async Task Update(MessageDto messageDto)
{ {
var a = _dbContext.Messages.Update(_mapper.Map<Message>(messageDto)); var a = _dbContext.Messages.Update(_mapper.Map<Message>(messageDto));
await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
} }

private void DeleteFiles(Guid basePathGuid)
{
// TODO: this path needs to be extracted somehow.
var basePath = Path.Combine(@"D:\secure-sharing\SecureSharing\wwwroot\files", basePathGuid.ToString());

Directory.Delete(basePath, true);
}
} }

+ 8
- 8
SecureSharing.Data/SecureSharing.Data.csproj Wyświetl plik

</PropertyGroup> </PropertyGroup>


<ItemGroup> <ItemGroup>
<PackageReference Include="AutoMapper" Version="11.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="6.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="6.0.9" />
<PackageReference Include="AutoMapper" Version="11.0.1"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="6.0.9"/>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.9"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.9"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="6.0.9"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.9"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.9">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.1"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0"/>
</ItemGroup> </ItemGroup>


<ItemGroup> <ItemGroup>
<Folder Include="Migrations" />
<Folder Include="Migrations"/>
</ItemGroup> </ItemGroup>


</Project> </Project>

+ 17
- 24
SecureSharing/Controllers/HomeController.cs Wyświetl plik

using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles; using Microsoft.AspNetCore.StaticFiles;
using Org.BouncyCastle.Ocsp;
using SecureSharing.Business.Dtos; using SecureSharing.Business.Dtos;
using SecureSharing.Business.Interfaces; using SecureSharing.Business.Interfaces;
using SecureSharing.Data.Data; using SecureSharing.Data.Data;
[Authorize] [Authorize]
public sealed class HomeController : Controller public sealed class HomeController : Controller
{ {
private const string DefaultPath = "files";
private const string DefaultPathTmp = "filestmp";
private readonly ILogger<HomeController> _logger; private readonly ILogger<HomeController> _logger;
private readonly IMessageService _messageService; private readonly IMessageService _messageService;
private readonly IModelFactory _modelFactory; private readonly IModelFactory _modelFactory;
private readonly IWebHostEnvironment _webHostEnvironment; private readonly IWebHostEnvironment _webHostEnvironment;
private const string DefaultPath = "files";
private const string DefaultPathTmp = "filestmp";
public HomeController(ILogger<HomeController> logger, IMessageService messageService, IModelFactory modelFactory, IWebHostEnvironment webHostEnvironment)

public HomeController(ILogger<HomeController> logger, IMessageService messageService, IModelFactory modelFactory,
IWebHostEnvironment webHostEnvironment)
{ {
_logger = logger; _logger = logger;
_messageService = messageService; _messageService = messageService;
var files = Request.Form.Files.ToList(); var files = Request.Form.Files.ToList();
var basePath = Path.Combine(_webHostEnvironment.WebRootPath.Split('/')[0], DefaultPathTmp, code); var basePath = Path.Combine(_webHostEnvironment.WebRootPath.Split('/')[0], DefaultPathTmp, code);
Directory.CreateDirectory(basePath); Directory.CreateDirectory(basePath);
foreach (var formFile in files) foreach (var formFile in files)
{ {
if (formFile.Length <= 0)
if (formFile.Length <= 0)
continue; continue;
var filePath = Path.Combine(basePath, formFile.FileName); var filePath = Path.Combine(basePath, formFile.FileName);
await using var stream = new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite); await using var stream = new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite);
await formFile.CopyToAsync(stream); await formFile.CopyToAsync(stream);
public async Task<IActionResult> CreateMessage(MessageModel model) public async Task<IActionResult> CreateMessage(MessageModel model)
{ {
if (string.IsNullOrWhiteSpace(model.Text) && model.Files.Count == 0 && string.IsNullOrEmpty(model.FilesAsText)) if (string.IsNullOrWhiteSpace(model.Text) && model.Files.Count == 0 && string.IsNullOrEmpty(model.FilesAsText))
{
return Redirect("/"); return Redirect("/");
}

var message = new MessageDto { Text = model.Text }; var message = new MessageDto { Text = model.Text };
await UploadFiles(model, message); await UploadFiles(model, message);


var code = await _messageService.Create(message, model.ChosenPeriod); var code = await _messageService.Create(message, model.ChosenPeriod);
return RedirectToAction("Link", "Home", new { code = code, share = true });
return RedirectToAction("Link", "Home", new { code, share = true });
} }


private async Task UploadFiles(MessageModel model, MessageDto message) private async Task UploadFiles(MessageModel model, MessageDto message)
{ {
var basePath = Path.Combine(_webHostEnvironment.WebRootPath.Split('/')[0], DefaultPath, message.Code.ToString());
var basePath = Path.Combine(_webHostEnvironment.WebRootPath.Split('/')[0], DefaultPath,
message.Code.ToString());
var basePathTemporary = Path.Combine(_webHostEnvironment.WebRootPath.Split('/')[0], DefaultPathTmp); var basePathTemporary = Path.Combine(_webHostEnvironment.WebRootPath.Split('/')[0], DefaultPathTmp);
Directory.CreateDirectory(basePath); Directory.CreateDirectory(basePath);


.ToList(); .ToList();
var fileNames = fileNamesTmp.Select(x => x.Split(':')[1]).ToList(); var fileNames = fileNamesTmp.Select(x => x.Split(':')[1]).ToList();


foreach (var file in fileNames)
{
message.FileNames.Add(new FileModel { Name = file });
}
foreach (var file in fileNames) message.FileNames.Add(new FileModel { Name = file });


for (var ind = 0; ind < directoryNames.Count; ind++) for (var ind = 0; ind < directoryNames.Count; ind++)
{ {
var directoryName = directoryNames[ind]; var directoryName = directoryNames[ind];
var directoryPath = Path.Combine(basePathTemporary, directoryName); var directoryPath = Path.Combine(basePathTemporary, directoryName);
var files = Directory.GetFiles(directoryPath); var files = Directory.GetFiles(directoryPath);
for (var i = 0; i < files.Length; i++) for (var i = 0; i < files.Length; i++)
{ {
// var file = files[i]; // var file = files[i];
} }
} }


foreach (var directory in directoryNames)
{
Directory.Delete(Path.Combine(basePathTemporary, directory), true);
}
foreach (var directory in directoryNames) Directory.Delete(Path.Combine(basePathTemporary, directory), true);


foreach (var formFile in model.Files) foreach (var formFile in model.Files)
{ {
await stream.CopyToAsync(memory); await stream.CopyToAsync(memory);
memory.Position = 0; memory.Position = 0;
new FileExtensionContentTypeProvider().TryGetContentType(filename, out var contentType); new FileExtensionContentTypeProvider().TryGetContentType(filename, out var contentType);
return contentType is null ? null : File(memory, contentType,Path.GetFileName(path));
return contentType is null ? null : File(memory, contentType, Path.GetFileName(path));
} }


[HttpGet] [HttpGet]

+ 2
- 3
SecureSharing/Infrastructure/ModelFactory.cs Wyświetl plik

using SecureSharing.Business.Interfaces; using SecureSharing.Business.Interfaces;
using SecureSharing.Data.Data;
using SecureSharing.Models; using SecureSharing.Models;


namespace SecureSharing.Infrastructure; namespace SecureSharing.Infrastructure;
{ {
MessageModel = new MessageModel MessageModel = new MessageModel
{ {
Code = code,
Code = code,
Text = message.Text, Text = message.Text,
FileNames = message.FileNames.Select(x => x.Name).ToList() FileNames = message.FileNames.Select(x => x.Name).ToList()
},
},
Share = share, Share = share,
IsValid = message.IsValid IsValid = message.IsValid
}; };

+ 1
- 0
SecureSharing/Models/MessageModel.cs Wyświetl plik

public PeriodOfValidity ChosenPeriod { get; set; } public PeriodOfValidity ChosenPeriod { get; set; }
public List<IFormFile> Files { get; init; } = new(); public List<IFormFile> Files { get; init; } = new();
public List<string> FileNames { get; init; } = new(); public List<string> FileNames { get; init; } = new();

public string FilesAsText { get; set; } = default!; public string FilesAsText { get; set; } = default!;
// public Dictionary<int, string> AvailablePeriods { get; set; } // public Dictionary<int, string> AvailablePeriods { get; set; }
} }

+ 3
- 10
SecureSharing/Program.cs Wyświetl plik

services.AddControllersWithViews(); services.AddControllersWithViews();
services.AddRazorPages(); services.AddRazorPages();
StartupExtensions.ConfigureServices(services); StartupExtensions.ConfigureServices(services);
services.AddDefaultIdentity<IdentityUser>(options =>
{
options.SignIn.RequireConfirmedAccount = false;
})
services.AddDefaultIdentity<IdentityUser>(options => { options.SignIn.RequireConfirmedAccount = false; })
.AddDefaultUI() .AddDefaultUI()
.AddRoles<IdentityRole>() .AddRoles<IdentityRole>()
.AddEntityFrameworkStores<AppDbContext>(); .AddEntityFrameworkStores<AppDbContext>();
services.AddScoped<IMessageService, MessageService>(); services.AddScoped<IMessageService, MessageService>();
services.AddScoped<IModelFactory, ModelFactory>(); services.AddScoped<IModelFactory, ModelFactory>();


services.AddAuthentication(o =>
{
o.DefaultScheme = GoogleDefaults.AuthenticationScheme;
})
services.AddAuthentication(o => { o.DefaultScheme = GoogleDefaults.AuthenticationScheme; })
.AddGoogle(options => .AddGoogle(options =>
{ {
options.ClientId = configuration["EmailSettings:ClientId"]; options.ClientId = configuration["EmailSettings:ClientId"];
options.ClientSecret =configuration["EmailSettings:ClientSecret"];
options.ClientSecret = configuration["EmailSettings:ClientSecret"];
}); });





// Add Quartz services // Add Quartz services
services.AddSingleton<IJobFactory, JobFactory>(); services.AddSingleton<IJobFactory, JobFactory>();
services.AddSingleton<ISchedulerFactory, StdSchedulerFactory>(); services.AddSingleton<ISchedulerFactory, StdSchedulerFactory>();

+ 0
- 2
SecureSharing/Quartz/JobFactory.cs Wyświetl plik

public void ReturnJob(IJob job) public void ReturnJob(IJob job)
{ {
if (_scopes.TryRemove(job, out var scope)) if (_scopes.TryRemove(job, out var scope))
{
// The Dispose() method ends the scope lifetime. // The Dispose() method ends the scope lifetime.
// Once Dispose is called, any scoped services that have been resolved from ServiceProvider will be disposed. // Once Dispose is called, any scoped services that have been resolved from ServiceProvider will be disposed.
scope.Dispose(); scope.Dispose();
}
} }
} }

+ 1
- 3
SecureSharing/Quartz/MessageDeletionJob.cs Wyświetl plik



public sealed class MessageDeletionJob : IJob public sealed class MessageDeletionJob : IJob
{ {
private readonly IMessageService _messageService;
private readonly string _basePath; private readonly string _basePath;
private readonly string _basePathTmp; private readonly string _basePathTmp;
private readonly IMessageService _messageService;


public MessageDeletionJob(IMessageService messageService, IWebHostEnvironment webHostEnvironment) public MessageDeletionJob(IMessageService messageService, IWebHostEnvironment webHostEnvironment)
{ {
var foldersToRemove = await _messageService.DeleteExpiredMessages(); var foldersToRemove = await _messageService.DeleteExpiredMessages();


foreach (var path in foldersToRemove.Select(folder => Path.Combine(_basePath, folder))) foreach (var path in foldersToRemove.Select(folder => Path.Combine(_basePath, folder)))
{
Directory.Delete(path, true); Directory.Delete(path, true);
}


Directory.Delete(_basePathTmp, true); Directory.Delete(_basePathTmp, true);
} }

+ 24
- 24
SecureSharing/SecureSharing.csproj Wyświetl plik

</PropertyGroup> </PropertyGroup>


<ItemGroup> <ItemGroup>
<PackageReference Include="AutoMapper" Version="11.0.1" />
<PackageReference Include="Microsoft.AspNet.Identity.Core" Version="2.2.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="6.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="6.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="6.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="6.0.9" />
<PackageReference Include="AutoMapper" Version="11.0.1"/>
<PackageReference Include="Microsoft.AspNet.Identity.Core" Version="2.2.3"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="6.0.9"/>
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="6.0.9"/>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.9"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="6.0.9"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="6.0.9"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.9"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.9">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.15.1" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="6.0.9" />
<PackageReference Include="Quartz" Version="3.5.0" />
<PackageReference Include="Serilog" Version="2.12.0" />
<PackageReference Include="Serilog.AspNetCore" Version="6.0.1" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.15.1"/>
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="6.0.9"/>
<PackageReference Include="Quartz" Version="3.5.0"/>
<PackageReference Include="Serilog" Version="2.12.0"/>
<PackageReference Include="Serilog.AspNetCore" Version="6.0.1"/>
</ItemGroup> </ItemGroup>


<ItemGroup> <ItemGroup>
<ProjectReference Include="..\SecureSharing.Business\SecureSharing.Business.csproj" />
<ProjectReference Include="..\SecureSharing.Data\SecureSharing.Data.csproj" />
<ProjectReference Include="..\SecureSharing.Business\SecureSharing.Business.csproj"/>
<ProjectReference Include="..\SecureSharing.Data\SecureSharing.Data.csproj"/>
</ItemGroup> </ItemGroup>


<ItemGroup> <ItemGroup>
<Folder Include="AppData\Errors\" />
<Folder Include="wwwroot\files" />
<Folder Include="AppData\Errors\"/>
<Folder Include="wwwroot\files"/>
</ItemGroup> </ItemGroup>


<ItemGroup> <ItemGroup>
<_ContentIncludedByDefault Remove="Areas\Identity\Pages\Account\Login.cshtml" />
<_ContentIncludedByDefault Remove="Areas\Identity\Pages\Account\LoginTestbe.cshtml" />
<_ContentIncludedByDefault Remove="Areas\Identity\Pages\Account\Register.cshtml" />
<_ContentIncludedByDefault Remove="Areas\Identity\Pages\Account\ResetPassword.cshtml" />
<_ContentIncludedByDefault Remove="Areas\Identity\Pages\Account\_ViewImports.cshtml" />
<_ContentIncludedByDefault Remove="Areas\Identity\Pages\_ValidationScriptsPartial.cshtml" />
<_ContentIncludedByDefault Remove="Areas\Identity\Pages\_ViewImports.cshtml" />
<_ContentIncludedByDefault Remove="Areas\Identity\Pages\_ViewStart.cshtml" />
<_ContentIncludedByDefault Remove="Areas\Identity\Pages\Account\Login.cshtml"/>
<_ContentIncludedByDefault Remove="Areas\Identity\Pages\Account\LoginTestbe.cshtml"/>
<_ContentIncludedByDefault Remove="Areas\Identity\Pages\Account\Register.cshtml"/>
<_ContentIncludedByDefault Remove="Areas\Identity\Pages\Account\ResetPassword.cshtml"/>
<_ContentIncludedByDefault Remove="Areas\Identity\Pages\Account\_ViewImports.cshtml"/>
<_ContentIncludedByDefault Remove="Areas\Identity\Pages\_ValidationScriptsPartial.cshtml"/>
<_ContentIncludedByDefault Remove="Areas\Identity\Pages\_ViewImports.cshtml"/>
<_ContentIncludedByDefault Remove="Areas\Identity\Pages\_ViewStart.cshtml"/>
</ItemGroup> </ItemGroup>


</Project> </Project>

+ 3
- 3
SecureSharing/Views/Home/Index.cshtml Wyświetl plik

<input asp-for="FilesAsText" id="fileInputAsText" type="text" style="display:none"/> <input asp-for="FilesAsText" id="fileInputAsText" type="text" style="display:none"/>
</div> </div>
</div> </div>
<div class="label-text"> <div class="label-text">
Your files Your files
</div> </div>
<div id="filesUploaded"> <div id="filesUploaded">
</div> </div>
<button class=" btn btn-light share-button" type="submit">Share</button> <button class=" btn btn-light share-button" type="submit">Share</button>
</form> </form>



+ 4
- 2
SecureSharing/Views/Home/Link.cshtml Wyświetl plik

<div class="label-text link-show"> <div class="label-text link-show">
@foreach (var file in Model.MessageModel.FileNames) @foreach (var file in Model.MessageModel.FileNames)
{ {
<a asp-action="Download" asp-route-filename="@file" asp-route-code="@Model.MessageModel.Code">@file</a><br/>
<a asp-action="Download" asp-route-filename="@file" asp-route-code="@Model.MessageModel.Code">@file</a>
<br/>
} }
</div> </div>
} }
<div class="label-text link-show"> <div class="label-text link-show">
@foreach (var file in Model.MessageModel.FileNames) @foreach (var file in Model.MessageModel.FileNames)
{ {
<a asp-action="Download" asp-route-filename="@file" asp-route-code="@Model.MessageModel.Code">@file</a><br/>
<a asp-action="Download" asp-route-filename="@file" asp-route-code="@Model.MessageModel.Code">@file</a>
<br/>
} }
</div> </div>
} }

+ 1
- 1
SecureSharing/Views/Shared/_Layout.cshtml Wyświetl plik

<link rel="stylesheet" href="~/plugins/daterangepicker/daterangepicker.css"> <link rel="stylesheet" href="~/plugins/daterangepicker/daterangepicker.css">
<!-- summernote --> <!-- summernote -->
<link rel="stylesheet" href="~/plugins/summernote/summernote-bs4.min.css"> <link rel="stylesheet" href="~/plugins/summernote/summernote-bs4.min.css">
<link href="~/lib/jquery-confirm/jquery-confirm.min.css" rel="stylesheet"/> <link href="~/lib/jquery-confirm/jquery-confirm.min.css" rel="stylesheet"/>


<link rel="stylesheet" href="~/css/site.css"> <link rel="stylesheet" href="~/css/site.css">

Ładowanie…
Anuluj
Zapisz