From 60229a0c06ca4f221c2537a5cb653fb62f679779 Mon Sep 17 00:00:00 2001 From: Kieran Date: Tue, 25 Jan 2022 23:39:51 +0000 Subject: [PATCH] Basic --- VoidCat/Controllers/DownloadController.cs | 32 ++++++ VoidCat/Controllers/UploadController.cs | 38 +++++-- VoidCat/Model/Base58GuidConverter.cs | 21 ++++ .../Exceptions/VoidFileNotFoundException.cs | 10 ++ .../Exceptions/VoidNotAllowedException.cs | 8 ++ VoidCat/Model/Extensions.cs | 16 +++ VoidCat/Model/VoidFile.cs | 29 ++++- VoidCat/Model/VoidSettings.cs | 2 +- VoidCat/Program.cs | 4 +- VoidCat/Services/IFileIngressFactory.cs | 9 -- VoidCat/Services/IFileStorage.cs | 15 +++ VoidCat/Services/InMemoryStatsCollector.cs | 27 ++++- .../Services/LocalDiskFileIngressFactory.cs | 42 ------- VoidCat/Services/LocalDiskFileStorage.cs | 103 ++++++++++++++++++ VoidCat/VoidCat.csproj | 2 + VoidCat/appsettings.json | 2 +- VoidCat/spa/src/App.js | 15 +-- VoidCat/spa/src/Const.js | 32 ++++++ VoidCat/spa/src/FilePreview.js | 23 ++++ VoidCat/spa/src/FileUpload.css | 20 ++++ VoidCat/spa/src/FileUpload.js | 66 +++++++++++ VoidCat/spa/src/Uploader.js | 42 +++++++ VoidCat/spa/src/Util.js | 27 +++++ VoidCat/spa/src/index.css | 16 ++- 24 files changed, 511 insertions(+), 90 deletions(-) create mode 100644 VoidCat/Controllers/DownloadController.cs create mode 100644 VoidCat/Model/Base58GuidConverter.cs create mode 100644 VoidCat/Model/Exceptions/VoidFileNotFoundException.cs create mode 100644 VoidCat/Model/Exceptions/VoidNotAllowedException.cs create mode 100644 VoidCat/Model/Extensions.cs delete mode 100644 VoidCat/Services/IFileIngressFactory.cs create mode 100644 VoidCat/Services/IFileStorage.cs delete mode 100644 VoidCat/Services/LocalDiskFileIngressFactory.cs create mode 100644 VoidCat/Services/LocalDiskFileStorage.cs create mode 100644 VoidCat/spa/src/Const.js create mode 100644 VoidCat/spa/src/FilePreview.js create mode 100644 VoidCat/spa/src/FileUpload.css create mode 100644 VoidCat/spa/src/FileUpload.js create mode 100644 VoidCat/spa/src/Uploader.js create mode 100644 VoidCat/spa/src/Util.js diff --git a/VoidCat/Controllers/DownloadController.cs b/VoidCat/Controllers/DownloadController.cs new file mode 100644 index 0000000..1c75c40 --- /dev/null +++ b/VoidCat/Controllers/DownloadController.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using VoidCat.Model; +using VoidCat.Services; + +namespace VoidCat.Controllers; + +[Route("d")] +public class DownloadController : Controller +{ + private readonly IFileStorage _storage; + + public DownloadController(IFileStorage storage) + { + _storage = storage; + } + + [HttpGet] + [Route("{id}")] + public async Task DownloadFile([FromRoute] string id) + { + var gid = id.FromBase58Guid(); + var meta = await _storage.Get(gid); + if (meta == null) + { + Response.StatusCode = 404; + return; + } + + Response.ContentType = meta?.Metadata?.MimeType ?? "application/octet-stream"; + await _storage.Egress(gid, Response.Body, HttpContext.RequestAborted); + } +} \ No newline at end of file diff --git a/VoidCat/Controllers/UploadController.cs b/VoidCat/Controllers/UploadController.cs index 224512f..30d3180 100644 --- a/VoidCat/Controllers/UploadController.cs +++ b/VoidCat/Controllers/UploadController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; using VoidCat.Model; using VoidCat.Services; @@ -7,25 +8,44 @@ namespace VoidCat.Controllers [Route("upload")] public class UploadController : Controller { - private readonly IFileIngressFactory _fileIngress; - private readonly IStatsCollector _stats; + private readonly IFileStorage _storage; - public UploadController(IStatsCollector stats, IFileIngressFactory fileIngress) + public UploadController(IFileStorage storage) { - _stats = stats; - _fileIngress = fileIngress; + _storage = storage; } [HttpPost] - public Task UploadFile() + [DisableRequestSizeLimit] + public Task UploadFile() { return Request.HasFormContentType ? - saveFromForm() : _fileIngress.Ingress(Request.Body); + saveFromForm() : _storage.Ingress(Request.Body, HttpContext.RequestAborted); } - private Task saveFromForm() + [HttpGet] + [Route("{id}")] + public Task GetInfo([FromRoute] string id) { - return Task.FromResult(null); + return _storage.Get(id.FromBase58Guid()); } + + [HttpPatch] + [Route("{id}")] + public Task UpdateFileInfo([FromRoute]string id, [FromBody]UpdateFileInfoRequest request) + { + return _storage.UpdateInfo(new VoidFile() + { + Id = id.FromBase58Guid(), + Metadata = request.Metadata + }, request.EditSecret); + } + + private Task saveFromForm() + { + return Task.FromResult(null); + } + + public record UpdateFileInfoRequest([JsonConverter(typeof(Base58GuidConverter))] Guid EditSecret, VoidFileMeta Metadata); } } diff --git a/VoidCat/Model/Base58GuidConverter.cs b/VoidCat/Model/Base58GuidConverter.cs new file mode 100644 index 0000000..ea5510f --- /dev/null +++ b/VoidCat/Model/Base58GuidConverter.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace VoidCat.Model; + +public class Base58GuidConverter : JsonConverter +{ + public override void WriteJson(JsonWriter writer, Guid value, JsonSerializer serializer) + { + writer.WriteValue(value.ToBase58()); + } + + public override Guid ReadJson(JsonReader reader, Type objectType, Guid existingValue, bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.String && existingValue == Guid.Empty) + { + return (reader.Value as string)?.FromBase58Guid() ?? existingValue; + } + + return existingValue; + } +} \ No newline at end of file diff --git a/VoidCat/Model/Exceptions/VoidFileNotFoundException.cs b/VoidCat/Model/Exceptions/VoidFileNotFoundException.cs new file mode 100644 index 0000000..0bb456b --- /dev/null +++ b/VoidCat/Model/Exceptions/VoidFileNotFoundException.cs @@ -0,0 +1,10 @@ +namespace VoidCat.Model.Exceptions; + +public class VoidFileNotFoundException : Exception +{ + public VoidFileNotFoundException(Guid id) + { + Id = id; + } + public Guid Id { get; } +} \ No newline at end of file diff --git a/VoidCat/Model/Exceptions/VoidNotAllowedException.cs b/VoidCat/Model/Exceptions/VoidNotAllowedException.cs new file mode 100644 index 0000000..bad2d94 --- /dev/null +++ b/VoidCat/Model/Exceptions/VoidNotAllowedException.cs @@ -0,0 +1,8 @@ +namespace VoidCat.Model.Exceptions; + +public class VoidNotAllowedException : Exception +{ + public VoidNotAllowedException(string message) : base(message) + { + } +} \ No newline at end of file diff --git a/VoidCat/Model/Extensions.cs b/VoidCat/Model/Extensions.cs new file mode 100644 index 0000000..dd45b6a --- /dev/null +++ b/VoidCat/Model/Extensions.cs @@ -0,0 +1,16 @@ +namespace VoidCat.Model; + +public static class Extensions +{ + public static Guid FromBase58Guid(this string base58) + { + var enc = new NBitcoin.DataEncoders.Base58Encoder(); + return new Guid(enc.DecodeData(base58)); + } + + public static string ToBase58(this Guid id) + { + var enc = new NBitcoin.DataEncoders.Base58Encoder(); + return enc.EncodeData(id.ToByteArray()); + } +} \ No newline at end of file diff --git a/VoidCat/Model/VoidFile.cs b/VoidCat/Model/VoidFile.cs index 6cb04df..266aa28 100644 --- a/VoidCat/Model/VoidFile.cs +++ b/VoidCat/Model/VoidFile.cs @@ -1,15 +1,32 @@ -namespace VoidCat.Model + +using Newtonsoft.Json; + +namespace VoidCat.Model { public class VoidFile { - public Guid Id { get; init; } = Guid.NewGuid(); - - public string? Name { get; init; } - - public string? Description { get; init; } + [JsonConverter(typeof(Base58GuidConverter))] + public Guid Id { get; init; } + public VoidFileMeta Metadata { get; set; } + public ulong Size { get; init; } public DateTimeOffset Uploaded { get; init; } } + + public class InternalVoidFile : VoidFile + { + [JsonConverter(typeof(Base58GuidConverter))] + public Guid EditSecret { get; init; } + } + + public class VoidFileMeta + { + public string? Name { get; init; } + + public string? Description { get; init; } + + public string? MimeType { get; init; } + } } diff --git a/VoidCat/Model/VoidSettings.cs b/VoidCat/Model/VoidSettings.cs index 9820db7..e6708a3 100644 --- a/VoidCat/Model/VoidSettings.cs +++ b/VoidCat/Model/VoidSettings.cs @@ -2,6 +2,6 @@ { public class VoidSettings { - public string FilePath { get; init; } = "./data"; + public string DataDirectory { get; init; } = "./data"; } } diff --git a/VoidCat/Program.cs b/VoidCat/Program.cs index ec04855..ee4f539 100644 --- a/VoidCat/Program.cs +++ b/VoidCat/Program.cs @@ -5,8 +5,8 @@ var builder = WebApplication.CreateBuilder(args); var services = builder.Services; services.AddRouting(); -services.AddControllers(); -services.AddScoped(); +services.AddControllers().AddNewtonsoftJson(); +services.AddScoped(); services.AddScoped(); var configuration = builder.Configuration; diff --git a/VoidCat/Services/IFileIngressFactory.cs b/VoidCat/Services/IFileIngressFactory.cs deleted file mode 100644 index 220f27c..0000000 --- a/VoidCat/Services/IFileIngressFactory.cs +++ /dev/null @@ -1,9 +0,0 @@ -using VoidCat.Model; - -namespace VoidCat.Services -{ - public interface IFileIngressFactory - { - Task Ingress(Stream inStream); - } -} diff --git a/VoidCat/Services/IFileStorage.cs b/VoidCat/Services/IFileStorage.cs new file mode 100644 index 0000000..0b1af3f --- /dev/null +++ b/VoidCat/Services/IFileStorage.cs @@ -0,0 +1,15 @@ +using VoidCat.Model; + +namespace VoidCat.Services +{ + public interface IFileStorage + { + Task Get(Guid id); + + Task Ingress(Stream inStream, CancellationToken cts); + + Task Egress(Guid id, Stream outStream, CancellationToken cts); + + Task UpdateInfo(VoidFile patch, Guid editSecret); + } +} diff --git a/VoidCat/Services/InMemoryStatsCollector.cs b/VoidCat/Services/InMemoryStatsCollector.cs index f994ac2..89db729 100644 --- a/VoidCat/Services/InMemoryStatsCollector.cs +++ b/VoidCat/Services/InMemoryStatsCollector.cs @@ -1,15 +1,36 @@ -namespace VoidCat.Services +using System.Collections.Concurrent; + +namespace VoidCat.Services { public class InMemoryStatsCollector : IStatsCollector { + private readonly ConcurrentDictionary _ingress = new(); + private readonly ConcurrentDictionary _egress = new(); + public ValueTask TrackIngress(Guid id, ulong amount) { - throw new NotImplementedException(); + if (_ingress.ContainsKey(id) && _ingress.TryGetValue(id, out var v)) + { + _ingress.TryUpdate(id, v + amount, v); + } + else + { + _ingress.TryAdd(id, amount); + } + return ValueTask.CompletedTask; } public ValueTask TrackEgress(Guid id, ulong amount) { - throw new NotImplementedException(); + if (_egress.ContainsKey(id) && _egress.TryGetValue(id, out var v)) + { + _egress.TryUpdate(id, v + amount, v); + } + else + { + _egress.TryAdd(id, amount); + } + return ValueTask.CompletedTask; } } } diff --git a/VoidCat/Services/LocalDiskFileIngressFactory.cs b/VoidCat/Services/LocalDiskFileIngressFactory.cs deleted file mode 100644 index a16971d..0000000 --- a/VoidCat/Services/LocalDiskFileIngressFactory.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Buffers; -using VoidCat.Model; - -namespace VoidCat.Services; - -public class LocalDiskFileIngressFactory : IFileIngressFactory -{ - private readonly VoidSettings _settings; - private readonly IStatsCollector _stats; - - public LocalDiskFileIngressFactory(VoidSettings settings, IStatsCollector stats) - { - _settings = settings; - _stats = stats; - } - - public async Task Ingress(Stream inStream) - { - var id = Guid.NewGuid(); - var fPath = mapPath(id); - using var fsTemp = new FileStream(fPath, FileMode.Create, FileAccess.ReadWrite); - - var buffer = MemoryPool.Shared.Rent(); - var total = 0UL; - var readLength = 0; - while ((readLength = await inStream.ReadAsync(buffer.Memory)) > 0) - { - await fsTemp.WriteAsync(buffer.Memory[..readLength]); - await _stats.TrackIngress(id, (ulong)readLength); - total += (ulong)readLength; - } - - return new() - { - Id = id, - Size = total - }; - } - - private string mapPath(Guid id) => - Path.Join(_settings.FilePath, id.ToString()); -} \ No newline at end of file diff --git a/VoidCat/Services/LocalDiskFileStorage.cs b/VoidCat/Services/LocalDiskFileStorage.cs new file mode 100644 index 0000000..78073b2 --- /dev/null +++ b/VoidCat/Services/LocalDiskFileStorage.cs @@ -0,0 +1,103 @@ +using System.Buffers; +using Newtonsoft.Json; +using VoidCat.Model; +using VoidCat.Model.Exceptions; + +namespace VoidCat.Services; + +public class LocalDiskFileIngressFactory : IFileStorage +{ + private readonly VoidSettings _settings; + private readonly IStatsCollector _stats; + + public LocalDiskFileIngressFactory(VoidSettings settings, IStatsCollector stats) + { + _settings = settings; + _stats = stats; + + if (!Directory.Exists(_settings.DataDirectory)) + { + Directory.CreateDirectory(_settings.DataDirectory); + } + } + + public async Task Get(Guid id) + { + var path = MapMeta(id); + if (!File.Exists(path)) throw new VoidFileNotFoundException(id); + + var json = await File.ReadAllTextAsync(path); + return JsonConvert.DeserializeObject(json); + } + + public async Task Egress(Guid id, Stream outStream, CancellationToken cts) + { + var path = MapPath(id); + if (!File.Exists(path)) throw new VoidFileNotFoundException(id); + + await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read); + using var buffer = MemoryPool.Shared.Rent(); + var readLength = 0; + while ((readLength = await fs.ReadAsync(buffer.Memory, cts)) > 0) + { + await outStream.WriteAsync(buffer.Memory[..readLength], cts); + await _stats.TrackEgress(id, (ulong)readLength); + } + } + + public async Task Ingress(Stream inStream, CancellationToken cts) + { + var id = Guid.NewGuid(); + var fPath = MapPath(id); + await using var fsTemp = new FileStream(fPath, FileMode.Create, FileAccess.ReadWrite); + + using var buffer = MemoryPool.Shared.Rent(); + var total = 0UL; + var readLength = 0; + while ((readLength = await inStream.ReadAsync(buffer.Memory, cts)) > 0) + { + await fsTemp.WriteAsync(buffer.Memory[..readLength], cts); + await _stats.TrackIngress(id, (ulong)readLength); + total += (ulong)readLength; + } + + var fm = new InternalVoidFile() + { + Id = id, + Size = total, + Uploaded = DateTimeOffset.UtcNow, + EditSecret = Guid.NewGuid() + }; + + var mPath = MapMeta(id); + var json = JsonConvert.SerializeObject(fm); + await File.WriteAllTextAsync(mPath, json, cts); + return fm; + } + + public async Task UpdateInfo(VoidFile patch, Guid editSecret) + { + var path = MapMeta(patch.Id); + if (!File.Exists(path)) throw new VoidFileNotFoundException(patch.Id); + + var oldJson = await File.ReadAllTextAsync(path); + var oldObj = JsonConvert.DeserializeObject(oldJson); + + if (oldObj?.EditSecret != editSecret) + { + throw new VoidNotAllowedException("Edit secret incorrect"); + } + + // only patch metadata + oldObj.Metadata = patch.Metadata; + + var json = JsonConvert.SerializeObject(oldObj); + await File.WriteAllTextAsync(path, json); + } + + private string MapPath(Guid id) => + Path.Join(_settings.DataDirectory, id.ToString()); + + private string MapMeta(Guid id) => + Path.ChangeExtension(MapPath(id), ".json"); +} \ No newline at end of file diff --git a/VoidCat/VoidCat.csproj b/VoidCat/VoidCat.csproj index 194f634..962d4a4 100644 --- a/VoidCat/VoidCat.csproj +++ b/VoidCat/VoidCat.csproj @@ -11,7 +11,9 @@ + + diff --git a/VoidCat/appsettings.json b/VoidCat/appsettings.json index d25a23f..ac8144e 100644 --- a/VoidCat/appsettings.json +++ b/VoidCat/appsettings.json @@ -7,6 +7,6 @@ }, "AllowedHosts": "*", "Settings": { - "VoidSettings": "./data" + "DataDirectory": "./data" } } diff --git a/VoidCat/spa/src/App.js b/VoidCat/spa/src/App.js index d96bb11..e5a0ee2 100644 --- a/VoidCat/spa/src/App.js +++ b/VoidCat/spa/src/App.js @@ -1,17 +1,10 @@ import './App.css'; +import {FilePreview} from "./FilePreview"; +import {Uploader} from "./Uploader"; function App() { - - function selectFiles(e) { - - } - return ( -
-
-

Drop files here!

-
-
- ); + let hasPath = window.location.pathname !== "/"; + return hasPath ? : ; } export default App; diff --git a/VoidCat/spa/src/Const.js b/VoidCat/spa/src/Const.js new file mode 100644 index 0000000..d61a591 --- /dev/null +++ b/VoidCat/spa/src/Const.js @@ -0,0 +1,32 @@ +/** + * @constant {number} - Size of 1 kiB + */ +export const kiB = Math.pow(1024, 1); +/** + * @constant {number} - Size of 1 MiB + */ +export const MiB = Math.pow(1024, 2); +/** + * @constant {number} - Size of 1 GiB + */ +export const GiB = Math.pow(1024, 3); +/** + * @constant {number} - Size of 1 TiB + */ +export const TiB = Math.pow(1024, 4); +/** + * @constant {number} - Size of 1 PiB + */ +export const PiB = Math.pow(1024, 5); +/** + * @constant {number} - Size of 1 EiB + */ +export const EiB = Math.pow(1024, 6); +/** + * @constant {number} - Size of 1 ZiB + */ +export const ZiB = Math.pow(1024, 7); +/** + * @constant {number} - Size of 1 YiB + */ +export const YiB = Math.pow(1024, 8); \ No newline at end of file diff --git a/VoidCat/spa/src/FilePreview.js b/VoidCat/spa/src/FilePreview.js new file mode 100644 index 0000000..0ab7461 --- /dev/null +++ b/VoidCat/spa/src/FilePreview.js @@ -0,0 +1,23 @@ +import {useEffect, useState} from "react"; + +export function FilePreview(props) { + let [info, setInfo] = useState(); + + async function loadInfo() { + let req = await fetch(`/upload/${props.id}`); + if(req.ok) { + let info = await req.json(); + setInfo(info); + } + } + + useEffect(() => { + loadInfo(); + }, []); + + return ( +
+ {info ? {info.metadata?.name ?? info.id} : "Not Found"} +
+ ); +} \ No newline at end of file diff --git a/VoidCat/spa/src/FileUpload.css b/VoidCat/spa/src/FileUpload.css new file mode 100644 index 0000000..7cd4dff --- /dev/null +++ b/VoidCat/spa/src/FileUpload.css @@ -0,0 +1,20 @@ +.upload { + display: flex; + padding: 10px; + border: 1px solid grey; + border-radius: 10px; + margin-top: 10px; +} + +.upload .info { + flex: 2; +} + +.upload .status { + flex: 1; +} + +.upload dt { + font-size: 12px; + color: grey; +} \ No newline at end of file diff --git a/VoidCat/spa/src/FileUpload.js b/VoidCat/spa/src/FileUpload.js new file mode 100644 index 0000000..0510ac6 --- /dev/null +++ b/VoidCat/spa/src/FileUpload.js @@ -0,0 +1,66 @@ +import {useEffect, useState} from "react"; + +import "./FileUpload.css"; +import {FormatBytes} from "./Util"; + +export function FileUpload(props) { + let [speed, setSpeed] = useState(0); + let [progress, setProgress] = useState(0); + let [result, setResult] = useState(); + + async function doUpload() { + let req = await fetch("/upload", { + method: "POST", + body: props.file, + headers: { + "content-type": "application/octet-stream" + } + }); + + if(req.ok) { + let rsp = await req.json(); + console.log(rsp); + setResult(rsp); + } + } + + function renderStatus() { + if(result) { + return ( +
+
Link:
+
{result.id}
+
+ ); + } else { + return ( +
+
Speed:
+
{FormatBytes(speed)}/s
+
Progress:
+
{(progress * 100).toFixed(0)}%
+
+ ); + } + } + useEffect(() => { + console.log(props.file); + doUpload(); + }, []); + + return ( +
+
+
+
Name:
+
{props.file.name}
+
Size:
+
{FormatBytes(props.file.size)}
+
+
+
+ {renderStatus()} +
+
+ ); +} \ No newline at end of file diff --git a/VoidCat/spa/src/Uploader.js b/VoidCat/spa/src/Uploader.js new file mode 100644 index 0000000..d038ba7 --- /dev/null +++ b/VoidCat/spa/src/Uploader.js @@ -0,0 +1,42 @@ +import {Fragment, useState} from "react"; +import {FileUpload} from "./FileUpload"; + +export function Uploader(props) { + let [files, setFiles] = useState([]); + + function selectFiles(e) { + let i = document.createElement('input'); + i.setAttribute('type', 'file'); + i.setAttribute('multiple', ''); + i.addEventListener('change', function (evt) { + setFiles(evt.target.files); + }); + i.click(); + } + + function renderUploads() { + let fElm = []; + for(let f of files) { + fElm.push(); + } + return ( + + {fElm} + + ); + } + + function renderDrop() { + return ( +
+

Drop files here!

+
+ ); + } + + return ( +
+ {files.length === 0 ? renderDrop() : renderUploads()} +
+ ); +} \ No newline at end of file diff --git a/VoidCat/spa/src/Util.js b/VoidCat/spa/src/Util.js new file mode 100644 index 0000000..ce8c3b0 --- /dev/null +++ b/VoidCat/spa/src/Util.js @@ -0,0 +1,27 @@ +import * as Const from "./Const"; +/** + * Formats bytes into binary notation + * @param {number} b - The value in bytes + * @param {number} [f=2] - The number of decimal places to use + * @returns {string} Bytes formatted in binary notation + */ +export function FormatBytes(b, f) { + f = typeof f === 'number' ? 2 : f; + if (b >= Const.YiB) + return (b / Const.YiB).toFixed(f) + ' YiB'; + if (b >= Const.ZiB) + return (b / Const.ZiB).toFixed(f) + ' ZiB'; + if (b >= Const.EiB) + return (b / Const.EiB).toFixed(f) + ' EiB'; + if (b >= Const.PiB) + return (b / Const.PiB).toFixed(f) + ' PiB'; + if (b >= Const.TiB) + return (b / Const.TiB).toFixed(f) + ' TiB'; + if (b >= Const.GiB) + return (b / Const.GiB).toFixed(f) + ' GiB'; + if (b >= Const.MiB) + return (b / Const.MiB).toFixed(f) + ' MiB'; + if (b >= Const.kiB) + return (b / Const.kiB).toFixed(f) + ' KiB'; + return b.toFixed(f) + ' B' +} \ No newline at end of file diff --git a/VoidCat/spa/src/index.css b/VoidCat/spa/src/index.css index b6ea139..7944e43 100644 --- a/VoidCat/spa/src/index.css +++ b/VoidCat/spa/src/index.css @@ -1,15 +1,19 @@ +@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;700&display=swap'); + body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; + font-family: 'Source Code Pro', monospace; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; background-color: black; color: white; } -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; +a { + color: white; + text-decoration: none; } + +a:hover { + text-decoration: underline; +} \ No newline at end of file