mirror of
https://git.v0l.io/Kieran/void.cat.git
synced 2025-04-12 04:49:03 +02:00
Update admin page
This commit is contained in:
parent
3bffcdeb13
commit
e9a0b8fb1c
@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using VoidCat.Model;
|
||||
using VoidCat.Services.Abstractions;
|
||||
|
||||
namespace VoidCat.Controllers.Admin;
|
||||
|
||||
@ -8,5 +9,24 @@ namespace VoidCat.Controllers.Admin;
|
||||
[Authorize(Policy = Policies.RequireAdmin)]
|
||||
public class AdminController : Controller
|
||||
{
|
||||
|
||||
}
|
||||
private readonly IFileStore _fileStore;
|
||||
|
||||
public AdminController(IFileStore fileStore)
|
||||
{
|
||||
_fileStore = fileStore;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("file")]
|
||||
public IAsyncEnumerable<PublicVoidFile> ListFiles()
|
||||
{
|
||||
return _fileStore.ListFiles();
|
||||
}
|
||||
|
||||
[HttpDelete]
|
||||
[Route("file/{id}")]
|
||||
public ValueTask DeleteFile([FromRoute] string id)
|
||||
{
|
||||
return _fileStore.DeleteFile(id.FromBase58Guid());
|
||||
}
|
||||
}
|
@ -61,12 +61,13 @@ public class AuthController : Controller
|
||||
|
||||
var claims = new Claim[]
|
||||
{
|
||||
new(ClaimTypes.Sid, user.Id.ToString()),
|
||||
new(ClaimTypes.Expiration, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
|
||||
new(ClaimTypes.AuthorizationDecision, string.Join(",", user.Roles))
|
||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
new(JwtRegisteredClaimNames.Exp, DateTimeOffset.UtcNow.AddHours(6).ToUnixTimeSeconds().ToString()),
|
||||
new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
|
||||
new(ClaimTypes.Role, string.Join(",", user.Roles))
|
||||
};
|
||||
|
||||
return new JwtSecurityToken(_settings.JwtSettings.Issuer, claims: claims, expires: DateTime.UtcNow.AddHours(6),
|
||||
return new JwtSecurityToken(_settings.JwtSettings.Issuer, claims: claims,
|
||||
signingCredentials: credentials);
|
||||
}
|
||||
|
||||
|
@ -117,12 +117,12 @@ public static class Extensions
|
||||
case "sha256":
|
||||
{
|
||||
var hash = SHA256.Create().ComputeHash(bytes);
|
||||
return $"sha256:${hash.ToHex()}";
|
||||
return $"sha256:{hash.ToHex()}";
|
||||
}
|
||||
case "sha512":
|
||||
{
|
||||
var hash = SHA512.Create().ComputeHash(bytes);
|
||||
return $"sha512:${hash.ToHex()}";
|
||||
return $"sha512:{hash.ToHex()}";
|
||||
}
|
||||
case "pbkdf2":
|
||||
{
|
||||
@ -140,7 +140,7 @@ public static class Extensions
|
||||
}
|
||||
|
||||
var pbkdf2 = new Rfc2898DeriveBytes(bytes, salt, iterations);
|
||||
return $"pbkdf2:{salt.ToHex()}:${pbkdf2.GetBytes(salt.Length).ToHex()}";
|
||||
return $"pbkdf2:{salt.ToHex()}:{pbkdf2.GetBytes(salt.Length).ToHex()}";
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -12,5 +12,5 @@ public record NoPaywallConfig() : PaywallConfig(PaywallServices.None, new Paywal
|
||||
|
||||
public record StrikePaywallConfig(PaywallMoney Cost) : PaywallConfig(PaywallServices.Strike, Cost)
|
||||
{
|
||||
public string Handle { get; init; }
|
||||
public string Handle { get; init; } = null!;
|
||||
}
|
@ -24,9 +24,11 @@ namespace VoidCat.Model
|
||||
|
||||
public sealed record PublicVoidFile : VoidFile<VoidFileMeta>
|
||||
{
|
||||
public Bandwidth? Bandwidth { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PrivateVoidFile : VoidFile<SecretVoidFileMeta>
|
||||
{
|
||||
public Bandwidth? Bandwidth { get; init; }
|
||||
}
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
using VoidCat.Services;
|
||||
using VoidCat.Services.Abstractions;
|
||||
using VoidCat.Services.Strike;
|
||||
|
||||
namespace VoidCat.Model
|
||||
{
|
||||
@ -9,14 +8,18 @@ namespace VoidCat.Model
|
||||
|
||||
public TorSettings? TorSettings { get; init; }
|
||||
|
||||
public JwtSettings JwtSettings { get; init; } = new("void_cat_internal", "default_key");
|
||||
public JwtSettings JwtSettings { get; init; } = new("void_cat_internal", "default_key_void_cat_host");
|
||||
|
||||
public string? Redis { get; init; }
|
||||
|
||||
public StrikeApiSettings? Strike { get; init; }
|
||||
|
||||
public SmtpSettings? Smtp { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TorSettings(Uri TorControl, string PrivateKey, string ControlPassword);
|
||||
|
||||
public sealed record JwtSettings(string Issuer, string Key);
|
||||
|
||||
public sealed record SmtpSettings(string Address, string Username, string Password);
|
||||
}
|
||||
|
@ -5,8 +5,8 @@ using Newtonsoft.Json;
|
||||
using Prometheus;
|
||||
using StackExchange.Redis;
|
||||
using VoidCat.Model;
|
||||
using VoidCat.Services;
|
||||
using VoidCat.Services.Abstractions;
|
||||
using VoidCat.Services.Files;
|
||||
using VoidCat.Services.InMemory;
|
||||
using VoidCat.Services.Migrations;
|
||||
using VoidCat.Services.Paywall;
|
||||
@ -108,9 +108,11 @@ foreach (var migration in migrations)
|
||||
}
|
||||
|
||||
app.UseStaticFiles();
|
||||
app.UseAuthentication();
|
||||
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.UseEndpoints(ep =>
|
||||
{
|
||||
ep.MapControllers();
|
||||
|
@ -10,4 +10,6 @@ public interface IFileMetadataStore
|
||||
ValueTask Set(Guid id, SecretVoidFileMeta meta);
|
||||
|
||||
ValueTask Update(Guid id, SecretVoidFileMeta patch);
|
||||
|
||||
ValueTask Delete(Guid id);
|
||||
}
|
||||
|
@ -11,4 +11,6 @@ public interface IFileStore
|
||||
ValueTask Egress(EgressRequest request, Stream outStream, CancellationToken cts);
|
||||
|
||||
IAsyncEnumerable<PublicVoidFile> ListFiles();
|
||||
|
||||
ValueTask DeleteFile(Guid id);
|
||||
}
|
@ -3,16 +3,18 @@ using VoidCat.Model;
|
||||
using VoidCat.Model.Exceptions;
|
||||
using VoidCat.Services.Abstractions;
|
||||
|
||||
namespace VoidCat.Services;
|
||||
namespace VoidCat.Services.Files;
|
||||
|
||||
public class LocalDiskFileMetadataStore : IFileMetadataStore
|
||||
{
|
||||
private const string MetadataDir = "metadata-v3";
|
||||
private readonly ILogger<LocalDiskFileMetadataStore> _logger;
|
||||
private readonly VoidSettings _settings;
|
||||
|
||||
public LocalDiskFileMetadataStore(VoidSettings settings)
|
||||
|
||||
public LocalDiskFileMetadataStore(VoidSettings settings, ILogger<LocalDiskFileMetadataStore> logger)
|
||||
{
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
|
||||
var metaPath = Path.Combine(_settings.DataDirectory, MetadataDir);
|
||||
if (!Directory.Exists(metaPath))
|
||||
@ -20,24 +22,24 @@ public class LocalDiskFileMetadataStore : IFileMetadataStore
|
||||
Directory.CreateDirectory(metaPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public ValueTask<VoidFileMeta?> GetPublic(Guid id)
|
||||
{
|
||||
return GetMeta<VoidFileMeta>(id);
|
||||
}
|
||||
|
||||
|
||||
public ValueTask<SecretVoidFileMeta?> Get(Guid id)
|
||||
{
|
||||
return GetMeta<SecretVoidFileMeta>(id);
|
||||
}
|
||||
|
||||
|
||||
public async ValueTask Set(Guid id, SecretVoidFileMeta meta)
|
||||
{
|
||||
var path = MapMeta(id);
|
||||
var json = JsonConvert.SerializeObject(meta);
|
||||
await File.WriteAllTextAsync(path, json);
|
||||
}
|
||||
|
||||
|
||||
public async ValueTask Update(Guid id, SecretVoidFileMeta patch)
|
||||
{
|
||||
var oldMeta = await Get(id);
|
||||
@ -49,6 +51,18 @@ public class LocalDiskFileMetadataStore : IFileMetadataStore
|
||||
await Set(id, patch);
|
||||
}
|
||||
|
||||
public ValueTask Delete(Guid id)
|
||||
{
|
||||
var path = MapMeta(id);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
_logger.LogInformation("Deleting metadata file {Path}", path);
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private async ValueTask<TMeta?> GetMeta<TMeta>(Guid id)
|
||||
{
|
||||
var path = MapMeta(id);
|
||||
@ -57,7 +71,7 @@ public class LocalDiskFileMetadataStore : IFileMetadataStore
|
||||
var json = await File.ReadAllTextAsync(path);
|
||||
return JsonConvert.DeserializeObject<TMeta>(json);
|
||||
}
|
||||
|
||||
|
||||
private string MapMeta(Guid id) =>
|
||||
Path.ChangeExtension(Path.Join(_settings.DataDirectory, MetadataDir, id.ToString()), ".json");
|
||||
}
|
||||
}
|
@ -4,23 +4,27 @@ using VoidCat.Model;
|
||||
using VoidCat.Model.Exceptions;
|
||||
using VoidCat.Services.Abstractions;
|
||||
|
||||
namespace VoidCat.Services;
|
||||
namespace VoidCat.Services.Files;
|
||||
|
||||
public class LocalDiskFileStore : IFileStore
|
||||
{
|
||||
private const int BufferSize = 1024 * 1024;
|
||||
private const int BufferSize = 1_048_576;
|
||||
private readonly ILogger<LocalDiskFileStore> _logger;
|
||||
private readonly VoidSettings _settings;
|
||||
private readonly IAggregateStatsCollector _stats;
|
||||
private readonly IFileMetadataStore _metadataStore;
|
||||
private readonly IPaywallStore _paywallStore;
|
||||
private readonly IStatsReporter _statsReporter;
|
||||
|
||||
public LocalDiskFileStore(VoidSettings settings, IAggregateStatsCollector stats,
|
||||
IFileMetadataStore metadataStore, IPaywallStore paywallStore)
|
||||
public LocalDiskFileStore(ILogger<LocalDiskFileStore> logger, VoidSettings settings, IAggregateStatsCollector stats,
|
||||
IFileMetadataStore metadataStore, IPaywallStore paywallStore, IStatsReporter statsReporter)
|
||||
{
|
||||
_settings = settings;
|
||||
_stats = stats;
|
||||
_metadataStore = metadataStore;
|
||||
_paywallStore = paywallStore;
|
||||
_statsReporter = statsReporter;
|
||||
_logger = logger;
|
||||
|
||||
if (!Directory.Exists(_settings.DataDirectory))
|
||||
{
|
||||
@ -30,11 +34,12 @@ public class LocalDiskFileStore : IFileStore
|
||||
|
||||
public async ValueTask<PublicVoidFile?> Get(Guid id)
|
||||
{
|
||||
return new ()
|
||||
return new()
|
||||
{
|
||||
Id = id,
|
||||
Metadata = await _metadataStore.GetPublic(id),
|
||||
Paywall = await _paywallStore.GetConfig(id)
|
||||
Paywall = await _paywallStore.GetConfig(id),
|
||||
Bandwidth = await _statsReporter.GetBandwidth(id)
|
||||
};
|
||||
}
|
||||
|
||||
@ -114,20 +119,29 @@ public class LocalDiskFileStore : IFileStore
|
||||
foreach (var fe in Directory.EnumerateFiles(_settings.DataDirectory))
|
||||
{
|
||||
var filename = Path.GetFileNameWithoutExtension(fe);
|
||||
if (Path.HasExtension(fe)) continue; // real file does not have extension
|
||||
if (!Guid.TryParse(filename, out var id)) continue;
|
||||
|
||||
var meta = await _metadataStore.Get(id);
|
||||
if (meta != default)
|
||||
var vf = await Get(id);
|
||||
if (vf != default)
|
||||
{
|
||||
yield return new()
|
||||
{
|
||||
Id = id,
|
||||
Metadata = meta
|
||||
};
|
||||
yield return vf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DeleteFile(Guid id)
|
||||
{
|
||||
var fp = MapPath(id);
|
||||
if (File.Exists(fp))
|
||||
{
|
||||
_logger.LogInformation("Deleting file: {Path}", fp);
|
||||
File.Delete(fp);
|
||||
}
|
||||
|
||||
await _metadataStore.Delete(id);
|
||||
}
|
||||
|
||||
private async Task<(ulong, string)> IngressInternal(Guid id, Stream ingress, Stream fs, CancellationToken cts)
|
||||
{
|
||||
using var buffer = MemoryPool<byte>.Shared.Rent(BufferSize);
|
@ -1,5 +1,6 @@
|
||||
using VoidCat.Model.Paywall;
|
||||
using VoidCat.Services.Abstractions;
|
||||
using VoidCat.Services.Strike;
|
||||
|
||||
namespace VoidCat.Services.Paywall;
|
||||
|
||||
|
@ -14,10 +14,10 @@ public class PaywallStore : IPaywallStore
|
||||
|
||||
public async ValueTask<PaywallConfig?> GetConfig(Guid id)
|
||||
{
|
||||
var cfg = await _cache.Get<PaywallConfig>(ConfigKey(id));
|
||||
var cfg = await _cache.Get<NoPaywallConfig>(ConfigKey(id));
|
||||
return cfg?.Service switch
|
||||
{
|
||||
PaywallServices.None => await _cache.Get<NoPaywallConfig>(ConfigKey(id)),
|
||||
PaywallServices.None => cfg,
|
||||
PaywallServices.Strike => await _cache.Get<StrikePaywallConfig>(ConfigKey(id)),
|
||||
_ => default
|
||||
};
|
||||
|
@ -2,6 +2,7 @@
|
||||
using VoidCat.Model;
|
||||
using VoidCat.Model.Paywall;
|
||||
using VoidCat.Services.Abstractions;
|
||||
using VoidCat.Services.Strike;
|
||||
|
||||
namespace VoidCat.Services.Paywall;
|
||||
|
||||
|
@ -3,7 +3,7 @@ using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace VoidCat.Services;
|
||||
namespace VoidCat.Services.Strike;
|
||||
|
||||
public class StrikeApi
|
||||
{
|
@ -26,7 +26,7 @@ public class UserManager : IUserManager
|
||||
public async ValueTask<VoidUser> Register(string email, string password)
|
||||
{
|
||||
var existingUser = await _store.LookupUser(email);
|
||||
if (existingUser != default) throw new InvalidOperationException("User already exists");
|
||||
if (existingUser != Guid.Empty) throw new InvalidOperationException("User already exists");
|
||||
|
||||
var newUser = new VoidUser(Guid.NewGuid(), email, password.HashPassword());
|
||||
await _store.Set(newUser);
|
||||
|
@ -6,6 +6,7 @@
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.7.2",
|
||||
"feather-icons-react": "^0.5.0",
|
||||
"moment": "^2.29.1",
|
||||
"qrcode.react": "^1.0.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
|
@ -1,16 +1,25 @@
|
||||
import {useSelector} from "react-redux";
|
||||
import {Fragment} from "react";
|
||||
import {useSelector} from "react-redux";
|
||||
import {Login} from "../Login";
|
||||
import {FileList} from "./FileList";
|
||||
import {UserList} from "./UserList";
|
||||
|
||||
export function Admin(props) {
|
||||
export function Admin() {
|
||||
const auth = useSelector((state) => state.login.jwt);
|
||||
|
||||
if(!auth) {
|
||||
|
||||
if (!auth) {
|
||||
return <Login/>;
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<h3>Admin</h3>
|
||||
</div>
|
||||
<Fragment>
|
||||
<h2>Admin</h2>
|
||||
|
||||
<h4>Users</h4>
|
||||
<UserList/>
|
||||
|
||||
<h4>Files</h4>
|
||||
<FileList/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
3
VoidCat/spa/src/Admin/FileList.css
Normal file
3
VoidCat/spa/src/Admin/FileList.css
Normal file
@ -0,0 +1,3 @@
|
||||
table.file-list {
|
||||
width: 100%;
|
||||
}
|
82
VoidCat/spa/src/Admin/FileList.js
Normal file
82
VoidCat/spa/src/Admin/FileList.js
Normal file
@ -0,0 +1,82 @@
|
||||
import moment from "moment";
|
||||
import {Link} from "react-router-dom";
|
||||
import {useSelector} from "react-redux";
|
||||
import {useEffect, useState} from "react";
|
||||
import {FormatBytes} from "../Util";
|
||||
|
||||
import "./FileList.css";
|
||||
|
||||
export function FileList(props) {
|
||||
const auth = useSelector((state) => state.login.jwt);
|
||||
const [files, setFiles] = useState([]);
|
||||
|
||||
async function loadFileList() {
|
||||
let req = await fetch("/admin/file", {
|
||||
headers: {
|
||||
"authorization": `Bearer ${auth}`
|
||||
}
|
||||
});
|
||||
if (req.ok) {
|
||||
setFiles(await req.json());
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile(e, id) {
|
||||
e.target.disabled = true;
|
||||
|
||||
let req = await fetch(`/admin/file/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"authorization": `Bearer ${auth}`
|
||||
}
|
||||
});
|
||||
if (req.ok) {
|
||||
setFiles([
|
||||
...files.filter(a => a.id !== id)
|
||||
]);
|
||||
} else {
|
||||
alert("Failed to delete file!");
|
||||
}
|
||||
e.target.disabled = false;
|
||||
}
|
||||
|
||||
function renderItem(i) {
|
||||
const meta = i.metadata;
|
||||
const bw = i.bandwidth;
|
||||
|
||||
return (
|
||||
<tr key={i.id}>
|
||||
<td><Link to={`/${i.id}`}>{i.id.substring(0, 4)}...</Link></td>
|
||||
<td>{meta?.name}</td>
|
||||
<td>{meta?.uploaded ? moment(meta?.uploaded).fromNow() : null}</td>
|
||||
<td>{meta?.size ? FormatBytes(meta?.size, 2) : null}</td>
|
||||
<td>{bw ? FormatBytes(bw.egress, 2) : null}</td>
|
||||
<td>
|
||||
<button onClick={(e) => deleteFile(e, i.id)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadFileList()
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<table className="file-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Id</td>
|
||||
<td>Name</td>
|
||||
<td>Uploaded</td>
|
||||
<td>Size</td>
|
||||
<td>Egress</td>
|
||||
<td>Actions</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files?.map(a => renderItem(a))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
5
VoidCat/spa/src/Admin/UserList.js
Normal file
5
VoidCat/spa/src/Admin/UserList.js
Normal file
@ -0,0 +1,5 @@
|
||||
export function UserList() {
|
||||
return (
|
||||
<table></table>
|
||||
);
|
||||
}
|
8
VoidCat/spa/src/Login.css
Normal file
8
VoidCat/spa/src/Login.css
Normal file
@ -0,0 +1,8 @@
|
||||
.login .error-msg {
|
||||
color: red;
|
||||
padding: 10px;
|
||||
border: 1px solid red;
|
||||
border-radius: 10px;
|
||||
margin-top: 10px;
|
||||
width: fit-content;
|
||||
}
|
@ -2,15 +2,19 @@
|
||||
import {useDispatch} from "react-redux";
|
||||
import {setAuth} from "./LoginState";
|
||||
|
||||
import "./Login.css";
|
||||
|
||||
export function Login(props) {
|
||||
const [username, setUsername] = useState();
|
||||
const [password, setPassword] = useState();
|
||||
const [error, setError] = useState();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
async function login(e) {
|
||||
async function login(e, url) {
|
||||
e.target.disabled = true;
|
||||
setError(null);
|
||||
|
||||
let req = await fetch("/auth/login", {
|
||||
let req = await fetch(`/auth/${url}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username, password
|
||||
@ -21,7 +25,11 @@ export function Login(props) {
|
||||
});
|
||||
if (req.ok) {
|
||||
let rsp = await req.json();
|
||||
dispatch(setAuth(rsp.jwt));
|
||||
if(rsp.jwt) {
|
||||
dispatch(setAuth(rsp.jwt));
|
||||
} else {
|
||||
setError(rsp.error);
|
||||
}
|
||||
}
|
||||
e.target.disabled = false;
|
||||
}
|
||||
@ -35,7 +43,9 @@ export function Login(props) {
|
||||
<dt>Password:</dt>
|
||||
<dd><input type="password" onChange={(e) => setPassword(e.target.value)}/></dd>
|
||||
</dl>
|
||||
<button onClick={login}>Login</button>
|
||||
<button onClick={(e) => login(e, "login")}>Login</button>
|
||||
<button onClick={(e) => login(e, "register")}>Register</button>
|
||||
{error ? <div className="error-msg">{error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,16 +1,20 @@
|
||||
import {createSlice} from "@reduxjs/toolkit";
|
||||
|
||||
const LocalStorageKey = "token";
|
||||
|
||||
export const LoginState = createSlice({
|
||||
name: "Login",
|
||||
initialState: {
|
||||
jwt: null
|
||||
jwt: window.localStorage.getItem(LocalStorageKey)
|
||||
},
|
||||
reducers: {
|
||||
setAuth: (state, action) => {
|
||||
state.jwt = action.payload;
|
||||
window.localStorage.setItem(LocalStorageKey, state.jwt);
|
||||
},
|
||||
logout: (state) => {
|
||||
state.jwt = null;
|
||||
window.localStorage.removeItem(LocalStorageKey);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -5697,6 +5697,11 @@ mkdirp@^0.5.5, mkdirp@~0.5.1:
|
||||
dependencies:
|
||||
minimist "^1.2.5"
|
||||
|
||||
moment@^2.29.1:
|
||||
version "2.29.1"
|
||||
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
|
||||
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
|
||||
|
||||
ms@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
|
||||
|
Loading…
x
Reference in New Issue
Block a user