Cleanup/Formatting

This commit is contained in:
Kieran 2023-10-13 22:04:45 +01:00
parent ef4ca27f4b
commit 0408f77dea
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
86 changed files with 3940 additions and 2745 deletions

View File

@ -45,7 +45,7 @@ public class UserController : Controller
var requestedId = isMe ? loggedUser!.Value : id.FromBase58Guid(); var requestedId = isMe ? loggedUser!.Value : id.FromBase58Guid();
var user = await _store.Get(requestedId); var user = await _store.Get(requestedId);
if (user == default) return NotFound(); if (user == default) return NotFound();
if (loggedUser != requestedId && !user.Flags.HasFlag(UserFlags.PublicProfile)) if (loggedUser != requestedId && !user.Flags.HasFlag(UserFlags.PublicProfile) && !HttpContext.IsRole(Roles.Admin))
return NotFound(); return NotFound();
var isMyProfile = requestedId == user.Id; var isMyProfile = requestedId == user.Id;

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint your application uses
module.exports = absRequire(`eslint`);

View File

@ -0,0 +1,6 @@
{
"name": "eslint",
"version": "8.51.0-sdk",
"main": "./lib/api.js",
"type": "commonjs"
}

View File

@ -0,0 +1,5 @@
# This file is automatically generated by @yarnpkg/sdks.
# Manual changes might be lost!
integrations:
- vscode

20
VoidCat/spa/.yarn/sdks/prettier/index.js vendored Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const relPnpApiPath = "../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier
require(absPnpApiPath).setup();
}
}
// Defer to the real prettier your application uses
module.exports = absRequire(`prettier`);

View File

@ -0,0 +1,6 @@
{
"name": "prettier",
"version": "3.0.3-sdk",
"main": "./index.js",
"type": "commonjs"
}

View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsc.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsc.js your application uses
module.exports = absRequire(`typescript/lib/tsc.js`);

View File

@ -0,0 +1,272 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const moduleWrapper = (tsserver) => {
if (!process.versions.pnp) {
return tsserver;
}
const { isAbsolute } = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = (str) => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = (str) => str.startsWith("portal:/");
const normalize = (str) => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(
pnpApi.getDependencyTreeRoots().map((locator) => {
return `${locator.name}@${locator.reference}`;
}),
);
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (
isAbsolute(str) &&
!str.match(/^\^?(zip:|\/zip\/)/) &&
(str.match(/\.zip\//) || isVirtual(str))
) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (
locator &&
(dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) ||
isPortal(locator.reference))
) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`:
{
str = `^zip:${str}`;
}
break;
case `vscode <1.66`:
{
str = `^/zip/${str}`;
}
break;
case `vscode <1.68`:
{
str = `^/zip${str}`;
}
break;
case `vscode`:
{
str = `^/zip/${str}`;
}
break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`:
{
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
}
break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`:
{
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
}
break;
default:
{
str = `zip:${str}`;
}
break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`:
{
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
}
break;
case `neovim`:
{
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
}
break;
case `vscode`:
default:
{
return str.replace(
/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/,
process.platform === `win32` ? `` : `/`,
);
}
break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const { enablePluginsWithOptions: originalEnablePluginsWithOptions } =
ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function () {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const { onMessage: originalOnMessage, send: originalSend } =
Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === "string";
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (
process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/,
) ?? []
).map(Number);
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(
parsedMessage,
(key, value) => {
return typeof value === "string" ? fromEditorPath(value) : value;
},
);
return originalOnMessage.call(
this,
isStringMessage
? processedMessageJSON
: JSON.parse(processedMessageJSON),
);
},
send(/** @type {any} */ msg) {
return originalSend.call(
this,
JSON.parse(
JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
}),
),
);
},
});
return tsserver;
};
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserver.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsserver.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`));

View File

@ -0,0 +1,272 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const moduleWrapper = (tsserver) => {
if (!process.versions.pnp) {
return tsserver;
}
const { isAbsolute } = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = (str) => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = (str) => str.startsWith("portal:/");
const normalize = (str) => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(
pnpApi.getDependencyTreeRoots().map((locator) => {
return `${locator.name}@${locator.reference}`;
}),
);
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (
isAbsolute(str) &&
!str.match(/^\^?(zip:|\/zip\/)/) &&
(str.match(/\.zip\//) || isVirtual(str))
) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (
locator &&
(dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) ||
isPortal(locator.reference))
) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`:
{
str = `^zip:${str}`;
}
break;
case `vscode <1.66`:
{
str = `^/zip/${str}`;
}
break;
case `vscode <1.68`:
{
str = `^/zip${str}`;
}
break;
case `vscode`:
{
str = `^/zip/${str}`;
}
break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`:
{
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
}
break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`:
{
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
}
break;
default:
{
str = `zip:${str}`;
}
break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`:
{
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
}
break;
case `neovim`:
{
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
}
break;
case `vscode`:
default:
{
return str.replace(
/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/,
process.platform === `win32` ? `` : `/`,
);
}
break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const { enablePluginsWithOptions: originalEnablePluginsWithOptions } =
ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function () {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const { onMessage: originalOnMessage, send: originalSend } =
Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === "string";
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (
process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/,
) ?? []
).map(Number);
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(
parsedMessage,
(key, value) => {
return typeof value === "string" ? fromEditorPath(value) : value;
},
);
return originalOnMessage.call(
this,
isStringMessage
? processedMessageJSON
: JSON.parse(processedMessageJSON),
);
},
send(/** @type {any} */ msg) {
return originalSend.call(
this,
JSON.parse(
JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
}),
),
);
},
});
return tsserver;
};
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserverlibrary.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsserverlibrary.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`));

View File

@ -0,0 +1,20 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire } = require(`module`);
const { resolve } = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/typescript.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/typescript.js your application uses
module.exports = absRequire(`typescript/lib/typescript.js`);

View File

@ -0,0 +1,6 @@
{
"name": "typescript",
"version": "5.2.2-sdk",
"main": "./lib/typescript.js",
"type": "commonjs"
}

View File

@ -1 +1 @@
yarnPath: .yarn/releases/yarn-3.6.2.cjs yarnPath: .yarn/releases/yarn-3.6.4.cjs

View File

@ -7,5 +7,10 @@
"build": "yarn workspace @void-cat/api build && yarn workspace @void-cat/app build", "build": "yarn workspace @void-cat/api build && yarn workspace @void-cat/app build",
"start": "yarn workspace @void-cat/api build && yarn workspace @void-cat/app start" "start": "yarn workspace @void-cat/api build && yarn workspace @void-cat/app start"
}, },
"packageManager": "yarn@3.6.2" "packageManager": "yarn@3.6.4",
"devDependencies": {
"eslint": "^8.51.0",
"prettier": "^3.0.3",
"typescript": "^5.2.2"
}
} }

View File

@ -66,7 +66,7 @@ export class VoidApi {
stateChange?: StateChangeHandler, stateChange?: StateChangeHandler,
progress?: ProgressHandler, progress?: ProgressHandler,
proxyChallenge?: ProxyChallengeHandler, proxyChallenge?: ProxyChallengeHandler,
chunkSize?: number chunkSize?: number,
): VoidUploader { ): VoidUploader {
if (StreamUploader.canUse()) { if (StreamUploader.canUse()) {
return new StreamUploader( return new StreamUploader(
@ -76,7 +76,7 @@ export class VoidApi {
progress, progress,
proxyChallenge, proxyChallenge,
this.#auth, this.#auth,
chunkSize chunkSize,
); );
} else { } else {
return new XHRUploader( return new XHRUploader(
@ -86,7 +86,7 @@ export class VoidApi {
progress, progress,
proxyChallenge, proxyChallenge,
this.#auth, this.#auth,
chunkSize chunkSize,
); );
} }
} }
@ -142,7 +142,7 @@ export class VoidApi {
return this.#req<PagedResponse<VoidFileResponse>>( return this.#req<PagedResponse<VoidFileResponse>>(
"POST", "POST",
`/user/${uid}/files`, `/user/${uid}/files`,
pageReq pageReq,
); );
} }
@ -170,7 +170,7 @@ export class VoidApi {
return this.#req<PagedResponse<VoidFileResponse>>( return this.#req<PagedResponse<VoidFileResponse>>(
"POST", "POST",
"/admin/file", "/admin/file",
pageReq pageReq,
); );
} }
@ -182,7 +182,7 @@ export class VoidApi {
return this.#req<PagedResponse<AdminUserListResult>>( return this.#req<PagedResponse<AdminUserListResult>>(
"POST", "POST",
`/admin/users`, `/admin/users`,
pageReq pageReq,
); );
} }

View File

@ -11,32 +11,37 @@ import sjcl from "sjcl";
* @namespace * @namespace
*/ */
export const sjclcodec = { export const sjclcodec = {
/** Convert from a bitArray to an array of bytes. */ /** Convert from a bitArray to an array of bytes. */
fromBits: function (arr) { fromBits: function (arr) {
var out = [], bl = sjcl.bitArray.bitLength(arr), i, tmp; var out = [],
for (i = 0; i < bl / 8; i++) { bl = sjcl.bitArray.bitLength(arr),
if ((i & 3) === 0) { i,
tmp = arr[i / 4]; tmp;
} for (i = 0; i < bl / 8; i++) {
out.push(tmp >>> 24); if ((i & 3) === 0) {
tmp <<= 8; tmp = arr[i / 4];
} }
return out; out.push(tmp >>> 24);
}, tmp <<= 8;
/** Convert from an array of bytes to a bitArray. */
/** @return {bitArray} */
toBits: function (bytes) {
var out = [], i, tmp = 0;
for (i = 0; i < bytes.length; i++) {
tmp = tmp << 8 | bytes[i];
if ((i & 3) === 3) {
out.push(tmp);
tmp = 0;
}
}
if (i & 3) {
out.push(sjcl.bitArray.partial(8 * (i & 3), tmp));
}
return out;
} }
return out;
},
/** Convert from an array of bytes to a bitArray. */
/** @return {bitArray} */
toBits: function (bytes) {
var out = [],
i,
tmp = 0;
for (i = 0; i < bytes.length; i++) {
tmp = (tmp << 8) | bytes[i];
if ((i & 3) === 3) {
out.push(tmp);
tmp = 0;
}
}
if (i & 3) {
out.push(sjcl.bitArray.partial(8 * (i & 3), tmp));
}
return out;
},
}; };

View File

@ -1,176 +1,176 @@
export { VoidApi } from "./api" export { VoidApi } from "./api";
export { UploadState } from "./upload"; export { UploadState } from "./upload";
export { StreamEncryption } from "./stream-encryption"; export { StreamEncryption } from "./stream-encryption";
export class ApiError extends Error { export class ApiError extends Error {
readonly statusCode: number readonly statusCode: number;
constructor(statusCode: number, msg: string) { constructor(statusCode: number, msg: string) {
super(msg); super(msg);
this.statusCode = statusCode; this.statusCode = statusCode;
} }
} }
export interface LoginSession { export interface LoginSession {
jwt?: string jwt?: string;
profile?: Profile profile?: Profile;
error?: string error?: string;
} }
export interface AdminUserListResult { export interface AdminUserListResult {
user: AdminProfile user: AdminProfile;
uploads: number uploads: number;
} }
export interface Profile { export interface Profile {
id: string id: string;
avatar?: string avatar?: string;
name?: string name?: string;
created: string created: string;
lastLogin: string lastLogin: string;
roles: Array<string> roles: Array<string>;
publicProfile: boolean publicProfile: boolean;
publicUploads: boolean publicUploads: boolean;
needsVerification?: boolean needsVerification?: boolean;
} }
export interface PagedResponse<T> { export interface PagedResponse<T> {
totalResults: number, totalResults: number;
results: Array<T> results: Array<T>;
} }
export interface AdminProfile extends Profile { export interface AdminProfile extends Profile {
email: string email: string;
storage: string storage: string;
} }
export interface Bandwidth { export interface Bandwidth {
ingress: number ingress: number;
egress: number egress: number;
} }
export interface BandwidthPoint { export interface BandwidthPoint {
time: string time: string;
ingress: number ingress: number;
egress: number egress: number;
} }
export interface SiteInfoResponse { export interface SiteInfoResponse {
count: number count: number;
totalBytes: number totalBytes: number;
buildInfo: { buildInfo: {
version: string version: string;
gitHash: string gitHash: string;
buildTime: string buildTime: string;
} };
bandwidth: Bandwidth bandwidth: Bandwidth;
fileStores: Array<string> fileStores: Array<string>;
uploadSegmentSize: number uploadSegmentSize: number;
captchaSiteKey?: string captchaSiteKey?: string;
oAuthProviders: Array<string> oAuthProviders: Array<string>;
timeSeriesMetrics?: Array<BandwidthPoint> timeSeriesMetrics?: Array<BandwidthPoint>;
} }
export interface PagedRequest { export interface PagedRequest {
pageSize: number pageSize: number;
page: number page: number;
} }
export interface VoidUploadResult { export interface VoidUploadResult {
ok: boolean ok: boolean;
file?: VoidFileResponse file?: VoidFileResponse;
errorMessage?: string errorMessage?: string;
} }
export interface VoidFileResponse { export interface VoidFileResponse {
id: string, id: string;
metadata?: VoidFileMeta metadata?: VoidFileMeta;
payment?: Payment payment?: Payment;
uploader?: Profile uploader?: Profile;
bandwidth?: Bandwidth bandwidth?: Bandwidth;
virusScan?: VirusScanStatus virusScan?: VirusScanStatus;
} }
export interface VoidFileMeta { export interface VoidFileMeta {
name?: string name?: string;
description?: string description?: string;
size: number size: number;
uploaded: string uploaded: string;
mimeType: string mimeType: string;
digest?: string digest?: string;
expires?: string expires?: string;
url?: string url?: string;
editSecret?: string editSecret?: string;
encryptionParams?: string encryptionParams?: string;
magnetLink?: string magnetLink?: string;
storage: string storage: string;
} }
export interface VirusScanStatus { export interface VirusScanStatus {
isVirus: boolean isVirus: boolean;
names?: string names?: string;
} }
export interface Payment { export interface Payment {
service: PaymentServices service: PaymentServices;
required: boolean required: boolean;
currency: PaymentCurrencies currency: PaymentCurrencies;
amount: number amount: number;
strikeHandle?: string strikeHandle?: string;
} }
export interface SetPaymentConfigRequest { export interface SetPaymentConfigRequest {
editSecret: string editSecret: string;
currency: PaymentCurrencies currency: PaymentCurrencies;
amount: number amount: number;
strikeHandle?: string strikeHandle?: string;
required: boolean required: boolean;
} }
export interface PaymentOrder { export interface PaymentOrder {
id: string id: string;
status: PaymentOrderState status: PaymentOrderState;
orderLightning?: PaymentOrderLightning orderLightning?: PaymentOrderLightning;
} }
export interface PaymentOrderLightning { export interface PaymentOrderLightning {
invoice: string invoice: string;
expire: string expire: string;
} }
export interface ApiKey { export interface ApiKey {
id: string id: string;
created: string created: string;
expiry: string expiry: string;
token: string token: string;
} }
export enum PaymentCurrencies { export enum PaymentCurrencies {
BTC = 0, BTC = 0,
USD = 1, USD = 1,
EUR = 2, EUR = 2,
GBP = 3 GBP = 3,
} }
export enum PaymentServices { export enum PaymentServices {
None = 0, None = 0,
Strike = 1 Strike = 1,
} }
export enum PaymentOrderState { export enum PaymentOrderState {
Unpaid = 0, Unpaid = 0,
Paid = 1, Paid = 1,
Expired = 2 Expired = 2,
} }
export enum PagedSortBy { export enum PagedSortBy {
Name = 0, Name = 0,
Date = 1, Date = 1,
Size = 2, Size = 2,
Id = 3 Id = 3,
} }
export enum PageSortOrder { export enum PageSortOrder {
Asc = 0, Asc = 0,
Dsc = 1 Dsc = 1,
} }

View File

@ -4,115 +4,155 @@ import sjcl, { SjclCipher } from "sjcl";
import { buf2hex } from "./utils"; import { buf2hex } from "./utils";
interface EncryptionParams { interface EncryptionParams {
ts: number, ts: number;
cs: number cs: number;
} }
/** /**
* AES-GCM TransformStream * AES-GCM TransformStream
*/ */
export class StreamEncryption { export class StreamEncryption {
readonly #tagSize: number; readonly #tagSize: number;
readonly #chunkSize: number; readonly #chunkSize: number;
readonly #aes: SjclCipher; readonly #aes: SjclCipher;
readonly #key: sjcl.BitArray; readonly #key: sjcl.BitArray;
readonly #iv: sjcl.BitArray; readonly #iv: sjcl.BitArray;
constructor(key: string | sjcl.BitArray | undefined, iv: string | sjcl.BitArray | undefined, params?: EncryptionParams | string) { constructor(
if (!key && !iv) { key: string | sjcl.BitArray | undefined,
key = buf2hex(globalThis.crypto.getRandomValues(new Uint8Array(16))); iv: string | sjcl.BitArray | undefined,
iv = buf2hex(globalThis.crypto.getRandomValues(new Uint8Array(12))); params?: EncryptionParams | string,
} ) {
if (typeof key === "string" && typeof iv === "string") { if (!key && !iv) {
key = sjcl.codec.hex.toBits(key); key = buf2hex(globalThis.crypto.getRandomValues(new Uint8Array(16)));
iv = sjcl.codec.hex.toBits(iv); iv = buf2hex(globalThis.crypto.getRandomValues(new Uint8Array(12)));
} else if (!Array.isArray(key) || !Array.isArray(iv)) { }
throw "Key and IV must be hex string or bitArray"; if (typeof key === "string" && typeof iv === "string") {
} key = sjcl.codec.hex.toBits(key);
if (typeof params === "string") { iv = sjcl.codec.hex.toBits(iv);
params = JSON.parse(params) as EncryptionParams; } else if (!Array.isArray(key) || !Array.isArray(iv)) {
} throw "Key and IV must be hex string or bitArray";
}
this.#tagSize = params?.ts ?? 128; if (typeof params === "string") {
this.#chunkSize = params?.cs ?? (1024 * 1024 * 10); params = JSON.parse(params) as EncryptionParams;
this.#aes = new sjcl.cipher.aes(key);
this.#key = key;
this.#iv = iv;
console.log(`ts=${this.#tagSize}, cs=${this.#chunkSize}, key=${key}, iv=${this.#iv}`);
} }
/** this.#tagSize = params?.ts ?? 128;
* Return formatted encryption key this.#chunkSize = params?.cs ?? 1024 * 1024 * 10;
*/ this.#aes = new sjcl.cipher.aes(key);
getKey() { this.#key = key;
return `${sjcl.codec.hex.fromBits(this.#key)}:${sjcl.codec.hex.fromBits(this.#iv)}`; this.#iv = iv;
}
/** console.log(
* Get encryption params `ts=${this.#tagSize}, cs=${this.#chunkSize}, key=${key}, iv=${this.#iv}`,
*/ );
getParams() { }
return {
ts: this.#tagSize,
cs: this.#chunkSize
}
}
/** /**
* Get encryption TransformStream * Return formatted encryption key
*/ */
getEncryptionTransform() { getKey() {
return this._getCryptoStream(0); return `${sjcl.codec.hex.fromBits(this.#key)}:${sjcl.codec.hex.fromBits(
} this.#iv,
)}`;
}
/** /**
* Get decryption TransformStream * Get encryption params
*/ */
getDecryptionTransform() { getParams() {
return this._getCryptoStream(1); return {
} ts: this.#tagSize,
cs: this.#chunkSize,
};
}
_getCryptoStream(mode: number) { /**
let offset = 0; * Get encryption TransformStream
let buffer = new Uint8Array(this.#chunkSize + (mode === 1 ? this.#tagSize / 8 : 0)); */
return new TransformStream({ getEncryptionTransform() {
transform: async (chunk, controller) => { return this._getCryptoStream(0);
chunk = await chunk; }
try {
let toBuffer = Math.min(chunk.byteLength, buffer.byteLength - offset);
buffer.set(chunk.slice(0, toBuffer), offset);
offset += toBuffer;
if (offset === buffer.byteLength) { /**
let buff = sjclcodec.toBits(buffer); * Get decryption TransformStream
let encryptedBuf = sjclcodec.fromBits( */
mode === 0 ? getDecryptionTransform() {
sjcl.mode.gcm.encrypt(this.#aes, buff, this.#iv, [], this.#tagSize) : return this._getCryptoStream(1);
sjcl.mode.gcm.decrypt(this.#aes, buff, this.#iv, [], this.#tagSize) }
);
controller.enqueue(new Uint8Array(encryptedBuf));
offset = chunk.byteLength - toBuffer; _getCryptoStream(mode: number) {
buffer.set(chunk.slice(toBuffer)); let offset = 0;
} let buffer = new Uint8Array(
} catch (e) { this.#chunkSize + (mode === 1 ? this.#tagSize / 8 : 0),
console.error(e); );
throw e; return new TransformStream(
} {
}, transform: async (chunk, controller) => {
flush: (controller) => { chunk = await chunk;
let lastBuffer = buffer.slice(0, offset); try {
let buff = sjclcodec.toBits(lastBuffer); let toBuffer = Math.min(
let encryptedBuf = sjclcodec.fromBits( chunk.byteLength,
mode === 0 ? buffer.byteLength - offset,
sjcl.mode.gcm.encrypt(this.#aes, buff, this.#iv, [], this.#tagSize) : );
sjcl.mode.gcm.decrypt(this.#aes, buff, this.#iv, [], this.#tagSize) buffer.set(chunk.slice(0, toBuffer), offset);
); offset += toBuffer;
controller.enqueue(new Uint8Array(encryptedBuf));
if (offset === buffer.byteLength) {
let buff = sjclcodec.toBits(buffer);
let encryptedBuf = sjclcodec.fromBits(
mode === 0
? sjcl.mode.gcm.encrypt(
this.#aes,
buff,
this.#iv,
[],
this.#tagSize,
)
: sjcl.mode.gcm.decrypt(
this.#aes,
buff,
this.#iv,
[],
this.#tagSize,
),
);
controller.enqueue(new Uint8Array(encryptedBuf));
offset = chunk.byteLength - toBuffer;
buffer.set(chunk.slice(toBuffer));
} }
}, { } catch (e) {
highWaterMark: this.#chunkSize console.error(e);
}); throw e;
} }
},
flush: (controller) => {
let lastBuffer = buffer.slice(0, offset);
let buff = sjclcodec.toBits(lastBuffer);
let encryptedBuf = sjclcodec.fromBits(
mode === 0
? sjcl.mode.gcm.encrypt(
this.#aes,
buff,
this.#iv,
[],
this.#tagSize,
)
: sjcl.mode.gcm.decrypt(
this.#aes,
buff,
this.#iv,
[],
this.#tagSize,
),
);
controller.enqueue(new Uint8Array(encryptedBuf));
},
},
{
highWaterMark: this.#chunkSize,
},
);
}
} }

View File

@ -3,97 +3,115 @@ import { VoidUploadResult } from "./index";
import { StreamEncryption } from "./stream-encryption"; import { StreamEncryption } from "./stream-encryption";
export class StreamUploader extends VoidUploader { export class StreamUploader extends VoidUploader {
#encrypt?: StreamEncryption; #encrypt?: StreamEncryption;
static canUse() { static canUse() {
const rawUA = globalThis.navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./); const rawUA = globalThis.navigator.userAgent.match(
const majorVersion = rawUA ? parseInt(rawUA[2], 10) : 0; /Chrom(e|ium)\/([0-9]+)\./,
return majorVersion >= 105 && "getRandomValues" in globalThis.crypto && globalThis.location.protocol === "https:"; );
const majorVersion = rawUA ? parseInt(rawUA[2], 10) : 0;
return (
majorVersion >= 105 &&
"getRandomValues" in globalThis.crypto &&
globalThis.location.protocol === "https:"
);
}
canEncrypt(): boolean {
return true;
}
setEncryption(s: boolean) {
if (s) {
this.#encrypt = new StreamEncryption(undefined, undefined, undefined);
} else {
this.#encrypt = undefined;
} }
}
canEncrypt(): boolean { getEncryptionKey() {
return true; return this.#encrypt?.getKey();
}
async upload(headers?: HeadersInit): Promise<VoidUploadResult> {
this.onStateChange?.(UploadState.Hashing);
const hash = await this.digest(this.file);
let offset = 0;
const DefaultChunkSize = 1024 * 1024;
const rsBase = new ReadableStream(
{
start: async () => {
this.onStateChange?.(UploadState.Uploading);
},
pull: async (controller) => {
const chunk = await this.readChunk(
offset,
controller.desiredSize ?? DefaultChunkSize,
);
if (chunk.byteLength === 0) {
controller.close();
return;
}
this.onProgress?.(offset + chunk.byteLength);
offset += chunk.byteLength;
controller.enqueue(chunk);
},
cancel: (reason) => {
console.log(reason);
},
type: "bytes",
},
{
highWaterMark: DefaultChunkSize,
},
);
const absoluteUrl = `${this.uri}/upload`;
const reqHeaders = {
"Content-Type": "application/octet-stream",
"V-Content-Type": !this.file.type
? "application/octet-stream"
: this.file.type,
"V-Filename": "name" in this.file ? this.file.name : "",
"V-Full-Digest": hash,
} as Record<string, string>;
if (this.#encrypt) {
reqHeaders["V-EncryptionParams"] = JSON.stringify(
this.#encrypt!.getParams(),
);
} }
if (this.auth) {
setEncryption(s: boolean) { reqHeaders["Authorization"] = await this.auth(absoluteUrl, "POST");
if (s) {
this.#encrypt = new StreamEncryption(undefined, undefined, undefined);
} else {
this.#encrypt = undefined;
}
} }
const req = await fetch(absoluteUrl, {
method: "POST",
mode: "cors",
body: this.#encrypt
? rsBase.pipeThrough(this.#encrypt!.getEncryptionTransform())
: rsBase,
headers: {
...reqHeaders,
...headers,
},
// @ts-ignore New stream spec
duplex: "half",
});
getEncryptionKey() { if (req.ok) {
return this.#encrypt?.getKey() return (await req.json()) as VoidUploadResult;
} else {
throw new Error("Unknown error");
} }
}
async upload(headers?: HeadersInit): Promise<VoidUploadResult> { async readChunk(offset: number, size: number) {
this.onStateChange?.(UploadState.Hashing); if (offset > this.file.size) {
const hash = await this.digest(this.file); return new Uint8Array(0);
let offset = 0;
const DefaultChunkSize = 1024 * 1024;
const rsBase = new ReadableStream({
start: async () => {
this.onStateChange?.(UploadState.Uploading);
},
pull: async (controller) => {
const chunk = await this.readChunk(offset, controller.desiredSize ?? DefaultChunkSize);
if (chunk.byteLength === 0) {
controller.close();
return;
}
this.onProgress?.(offset + chunk.byteLength);
offset += chunk.byteLength
controller.enqueue(chunk);
},
cancel: (reason) => {
console.log(reason);
},
type: "bytes"
}, {
highWaterMark: DefaultChunkSize
});
const absoluteUrl = `${this.uri}/upload`;
const reqHeaders = {
"Content-Type": "application/octet-stream",
"V-Content-Type": !this.file.type ? "application/octet-stream" : this.file.type,
"V-Filename": "name" in this.file ? this.file.name : "",
"V-Full-Digest": hash,
} as Record<string, string>;
if (this.#encrypt) {
reqHeaders["V-EncryptionParams"] = JSON.stringify(this.#encrypt!.getParams());
}
if (this.auth) {
reqHeaders["Authorization"] = await this.auth(absoluteUrl, "POST");
}
const req = await fetch(absoluteUrl, {
method: "POST",
mode: "cors",
body: this.#encrypt ? rsBase.pipeThrough(this.#encrypt!.getEncryptionTransform()) : rsBase,
headers: {
...reqHeaders,
...headers
},
// @ts-ignore New stream spec
duplex: 'half'
});
if (req.ok) {
return await req.json() as VoidUploadResult;
} else {
throw new Error("Unknown error");
}
}
async readChunk(offset: number, size: number) {
if (offset > this.file.size) {
return new Uint8Array(0);
}
const end = Math.min(offset + size, this.file.size);
const blob = this.file.slice(offset, end, this.file.type);
const data = await blob.arrayBuffer();
return new Uint8Array(data);
} }
const end = Math.min(offset + size, this.file.size);
const blob = this.file.slice(offset, end, this.file.type);
const data = await blob.arrayBuffer();
return new Uint8Array(data);
}
} }

View File

@ -40,7 +40,7 @@ export abstract class VoidUploader {
progress?: ProgressHandler, progress?: ProgressHandler,
proxyChallenge?: ProxyChallengeHandler, proxyChallenge?: ProxyChallengeHandler,
auth?: AuthHandler, auth?: AuthHandler,
chunkSize?: number chunkSize?: number,
) { ) {
this.uri = uri; this.uri = uri;
this.file = file; this.file = file;

View File

@ -1,3 +1,5 @@
export function buf2hex(buffer: number[] | ArrayBuffer) { export function buf2hex(buffer: number[] | ArrayBuffer) {
return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join(''); return [...new Uint8Array(buffer)]
.map((x) => x.toString(16).padStart(2, "0"))
.join("");
} }

View File

@ -27,7 +27,7 @@ export class XHRUploader extends VoidUploader {
undefined, undefined,
1, 1,
1, 1,
headers headers,
); );
} }
} }
@ -35,7 +35,7 @@ export class XHRUploader extends VoidUploader {
async #doSplitXHRUpload( async #doSplitXHRUpload(
hash: string, hash: string,
splitSize: number, splitSize: number,
headers?: HeadersInit headers?: HeadersInit,
) { ) {
let xhr: VoidUploadResult | null = null; let xhr: VoidUploadResult | null = null;
const segments = Math.ceil(this.file.size / splitSize); const segments = Math.ceil(this.file.size / splitSize);
@ -49,7 +49,7 @@ export class XHRUploader extends VoidUploader {
xhr?.file?.metadata?.editSecret, xhr?.file?.metadata?.editSecret,
s + 1, s + 1,
segments, segments,
headers headers,
); );
if (!xhr.ok) { if (!xhr.ok) {
break; break;
@ -75,7 +75,7 @@ export class XHRUploader extends VoidUploader {
editSecret?: string, editSecret?: string,
part?: number, part?: number,
partOf?: number, partOf?: number,
headers?: HeadersInit headers?: HeadersInit,
) { ) {
this.onStateChange?.(UploadState.Uploading); this.onStateChange?.(UploadState.Uploading);
@ -112,11 +112,11 @@ export class XHRUploader extends VoidUploader {
req.setRequestHeader("Content-Type", "application/octet-stream"); req.setRequestHeader("Content-Type", "application/octet-stream");
req.setRequestHeader( req.setRequestHeader(
"V-Content-Type", "V-Content-Type",
!this.file.type ? "application/octet-stream" : this.file.type !this.file.type ? "application/octet-stream" : this.file.type,
); );
req.setRequestHeader( req.setRequestHeader(
"V-Filename", "V-Filename",
"name" in this.file ? this.file.name : "" "name" in this.file ? this.file.name : "",
); );
req.setRequestHeader("V-Full-Digest", fullDigest); req.setRequestHeader("V-Full-Digest", fullDigest);
req.setRequestHeader("V-Segment", `${part}/${partOf}`); req.setRequestHeader("V-Segment", `${part}/${partOf}`);

View File

@ -1,11 +1,11 @@
module.exports = { module.exports = {
webpack: { webpack: {
configure: { configure: {
resolve: { resolve: {
fallback: { fallback: {
"crypto": false crypto: false,
} },
} },
} },
} },
} };

View File

@ -1,4 +1,4 @@
declare module "*.png" { declare module "*.png" {
const value: string; const value: string;
export default value; export default value;
} }

View File

@ -1,17 +1,17 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico"/> <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000"/> <meta name="theme-color" content="#000000" />
<meta name="description" content="void.cat - free, simple file sharing."/> <meta name="description" content="void.cat - free, simple file sharing." />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo.png"/> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json"/> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>void.cat</title> <title>void.cat</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>

View File

@ -1,17 +1,17 @@
.admin { .admin {
width: 1024px; width: 1024px;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
.admin h2 { .admin h2 {
background-color: #222; background-color: #222;
padding: 10px; padding: 10px;
} }
.admin .btn { .admin .btn {
padding: 5px 8px; padding: 5px 8px;
border-radius: 3px; border-radius: 3px;
font-size: small; font-size: small;
margin: 2px; margin: 2px;
} }

View File

@ -1,53 +1,58 @@
import "./Admin.css"; import "./Admin.css";
import {useState} from "react"; import { useState } from "react";
import {useSelector} from "react-redux"; import { useSelector } from "react-redux";
import {Navigate} from "react-router-dom"; import { Navigate } from "react-router-dom";
import {AdminProfile} from "@void-cat/api"; import { AdminProfile } from "@void-cat/api";
import {UserList} from "./UserList"; import { UserList } from "./UserList";
import {VoidButton} from "../Components/Shared/VoidButton"; import { VoidButton } from "../Components/Shared/VoidButton";
import VoidModal from "../Components/Shared/VoidModal"; import VoidModal from "../Components/Shared/VoidModal";
import EditUser from "./EditUser"; import EditUser from "./EditUser";
import useApi from "Hooks/UseApi"; import useApi from "Hooks/UseApi";
import {RootState} from "Store"; import { RootState } from "Store";
import ImageGrid from "../Components/Shared/ImageGrid"; import ImageGrid from "../Components/Shared/ImageGrid";
export function Admin() { export function Admin() {
const auth = useSelector((state: RootState) => state.login.jwt); const auth = useSelector((state: RootState) => state.login.jwt);
const AdminApi = useApi(); const AdminApi = useApi();
const [editUser, setEditUser] = useState<AdminProfile>(); const [editUser, setEditUser] = useState<AdminProfile>();
async function deleteFile(id: string) { async function deleteFile(id: string) {
if (window.confirm(`Are you sure you want to delete: ${id}?`)) { if (window.confirm(`Are you sure you want to delete: ${id}?`)) {
try { try {
await AdminApi.adminDeleteFile(id); await AdminApi.adminDeleteFile(id);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alert("Failed to delete file!"); alert("Failed to delete file!");
} }
}
} }
}
if (!auth) { if (!auth) {
return <Navigate to="/login"/>; return <Navigate to="/login" />;
} else { } else {
return ( return (
<div className="admin"> <div className="admin">
<h2>Users</h2> <h2>Users</h2>
<UserList actions={(i) => [ <UserList
<VoidButton key={`delete-${i.id}`}>Delete</VoidButton>, actions={(i) => [
<VoidButton key={`edit-${i.id}`} onClick={() => setEditUser(i)}>Edit</VoidButton> <VoidButton key={`delete-${i.id}`}>Delete</VoidButton>,
]}/> <VoidButton key={`edit-${i.id}`} onClick={() => setEditUser(i)}>
Edit
</VoidButton>,
]}
/>
<h2>Files</h2> <h2>Files</h2>
<ImageGrid loadPage={r => AdminApi.adminListFiles(r)}/> <ImageGrid loadPage={(r) => AdminApi.adminListFiles(r)} />
{editUser && {editUser && (
<VoidModal title="Edit user"> <VoidModal title="Edit user">
<EditUser user={editUser} onClose={() => setEditUser(undefined)}/> <EditUser user={editUser} onClose={() => setEditUser(undefined)} />
</VoidModal>} </VoidModal>
</div> )}
); </div>
} );
}
} }

View File

@ -1,47 +1,68 @@
import {useState} from "react"; import { useState } from "react";
import {useSelector} from "react-redux"; import { useSelector } from "react-redux";
import {AdminProfile} from "@void-cat/api"; import { AdminProfile } from "@void-cat/api";
import {VoidButton} from "../Components/Shared/VoidButton"; import { VoidButton } from "../Components/Shared/VoidButton";
import useApi from "Hooks/UseApi"; import useApi from "Hooks/UseApi";
import {RootState} from "Store"; import { RootState } from "Store";
export default function EditUser({user, onClose}: {user: AdminProfile, onClose: () => void}) { export default function EditUser({
user,
onClose,
}: {
user: AdminProfile;
onClose: () => void;
}) {
const adminApi = useApi();
const fileStores = useSelector(
(state: RootState) => state.info?.info?.fileStores ?? ["local-disk"],
);
const [storage, setStorage] = useState(user.storage);
const [email, setEmail] = useState(user.email);
const adminApi = useApi(); async function updateUser() {
const fileStores = useSelector((state: RootState) => state.info?.info?.fileStores ?? ["local-disk"]) await adminApi.adminUpdateUser({
const [storage, setStorage] = useState(user.storage); ...user,
const [email, setEmail] = useState(user.email); email,
storage,
});
onClose();
}
async function updateUser() { return (
await adminApi.adminUpdateUser({ <>
...user, Editing user '{user.name}' ({user.id})
email, <dl>
storage <dt>Email:</dt>
}); <dd>
onClose(); <input
} type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</dd>
return ( <dt>File storage:</dt>
<> <dd>
Editing user '{user.name}' ({user.id}) <select value={storage} onChange={(e) => setStorage(e.target.value)}>
<dl> <option disabled={true}>Current: {storage}</option>
<dt>Email:</dt> {fileStores.map((e) => (
<dd><input type="text" value={email} onChange={(e) => setEmail(e.target.value)}/></dd> <option key={e}>{e}</option>
))}
</select>
</dd>
<dt>File storage:</dt> <dt>Roles:</dt>
<dd> <dd>
<select value={storage} onChange={(e) => setStorage(e.target.value)}> {user.roles.map((e) => (
<option disabled={true}>Current: {storage}</option> <span className="btn" key={e}>
{fileStores.map(e => <option key={e}>{e}</option>)} {e}
</select> </span>
</dd> ))}
</dd>
<dt>Roles:</dt> </dl>
<dd>{user.roles.map(e => <span className="btn" key={e}>{e}</span>)}</dd> <VoidButton onClick={() => updateUser()}>Save</VoidButton>
</dl> <VoidButton onClick={() => onClose()}>Cancel</VoidButton>
<VoidButton onClick={() => updateUser()}>Save</VoidButton> </>
<VoidButton onClick={() => onClose()}>Cancel</VoidButton> );
</>
);
} }

View File

@ -1,93 +1,103 @@
import {useDispatch} from "react-redux"; import { useDispatch } from "react-redux";
import {ReactNode, useEffect, useState} from "react"; import { ReactNode, useEffect, useState } from "react";
import moment from "moment"; import moment from "moment";
import {AdminProfile, AdminUserListResult, ApiError, PagedResponse, PagedSortBy, PageSortOrder} from "@void-cat/api"; import {
AdminProfile,
AdminUserListResult,
ApiError,
PagedResponse,
PagedSortBy,
PageSortOrder,
} from "@void-cat/api";
import {logout} from "../LoginState"; import { logout } from "../LoginState";
import {PageSelector} from "../Components/Shared/PageSelector"; import { PageSelector } from "../Components/Shared/PageSelector";
import useApi from "Hooks/UseApi"; import useApi from "Hooks/UseApi";
interface UserListProps { interface UserListProps {
actions: (u: AdminProfile) => ReactNode actions: (u: AdminProfile) => ReactNode;
} }
export function UserList({actions}: UserListProps) { export function UserList({ actions }: UserListProps) {
const AdminApi = useApi(); const AdminApi = useApi();
const dispatch = useDispatch(); const dispatch = useDispatch();
const [users, setUsers] = useState<PagedResponse<AdminUserListResult>>(); const [users, setUsers] = useState<PagedResponse<AdminUserListResult>>();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const pageSize = 10; const pageSize = 10;
const [accessDenied, setAccessDenied] = useState<boolean>(); const [accessDenied, setAccessDenied] = useState<boolean>();
async function loadUserList() { async function loadUserList() {
try { try {
const pageReq = { const pageReq = {
page: page, page: page,
pageSize, pageSize,
sortBy: PagedSortBy.Date, sortBy: PagedSortBy.Date,
sortOrder: PageSortOrder.Dsc sortOrder: PageSortOrder.Dsc,
}; };
const rsp = await AdminApi.adminUserList(pageReq); const rsp = await AdminApi.adminUserList(pageReq);
setUsers(rsp); setUsers(rsp);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
if (e instanceof ApiError) { if (e instanceof ApiError) {
if (e.statusCode === 401) { if (e.statusCode === 401) {
dispatch(logout()); dispatch(logout());
} else if (e.statusCode === 403) { } else if (e.statusCode === 403) {
setAccessDenied(true); setAccessDenied(true);
}
}
} }
}
} }
}
function renderUser(r: AdminUserListResult) { function renderUser(r: AdminUserListResult) {
return (
<tr key={r.user.id}>
<td><a href={`/u/${r.user.id}`}>{r.user.name}</a></td>
<td>{moment(r.user.created).fromNow()}</td>
<td>{moment(r.user.lastLogin).fromNow()}</td>
<td>{r.uploads}</td>
<td>{actions(r.user)}</td>
</tr>
);
}
useEffect(() => {
loadUserList().catch(console.error);
}, [page]);
if (accessDenied === true) {
return <h3>Access Denied</h3>;
}
return ( return (
<table> <tr key={r.user.id}>
<thead> <td>
<tr> <a href={`/u/${r.user.id}`}>{r.user.name}</a>
<th>Name</th> </td>
<th>Created</th> <td>{moment(r.user.created).fromNow()}</td>
<th>Last Login</th> <td>{moment(r.user.lastLogin).fromNow()}</td>
<th>Files</th> <td>{r.uploads}</td>
<th>Actions</th> <td>{actions(r.user)}</td>
</tr> </tr>
</thead>
<tbody>
{users && users.results.map(renderUser)}
</tbody>
<tbody>
<tr>
<td>
{users && <PageSelector
onSelectPage={(x) => setPage(x)}
page={page}
total={users.totalResults}
pageSize={pageSize}/>}
</td>
</tr>
</tbody>
</table>
); );
}
useEffect(() => {
loadUserList().catch(console.error);
}, [page]);
if (accessDenied === true) {
return <h3>Access Denied</h3>;
}
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Last Login</th>
<th>Files</th>
<th>Actions</th>
</tr>
</thead>
<tbody>{users && users.results.map(renderUser)}</tbody>
<tbody>
<tr>
<td>
{users && (
<PageSelector
onSelectPage={(x) => setPage(x)}
page={page}
total={users.totalResults}
pageSize={pageSize}
/>
)}
</td>
</tr>
</tbody>
</table>
);
} }

View File

@ -1,11 +1,11 @@
.page { .page {
width: 900px; width: 900px;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.page { .page {
width: 100vw; width: 100vw;
} }
} }

View File

@ -1,70 +1,79 @@
import './App.css'; import "./App.css";
import {createBrowserRouter, LoaderFunctionArgs, Outlet, RouterProvider} from "react-router-dom"; import {
import {Provider} from "react-redux"; createBrowserRouter,
LoaderFunctionArgs,
Outlet,
RouterProvider,
} from "react-router-dom";
import { Provider } from "react-redux";
import store from "./Store"; import store from "./Store";
import {FilePreview} from "./Pages/FilePreview"; import { FilePreview } from "./Pages/FilePreview";
import {HomePage} from "./Pages/HomePage"; import { HomePage } from "./Pages/HomePage";
import {Admin} from "./Admin/Admin"; import { Admin } from "./Admin/Admin";
import {UserLogin} from "./Pages/UserLogin"; import { UserLogin } from "./Pages/UserLogin";
import {ProfilePage} from "./Pages/Profile"; import { ProfilePage } from "./Pages/Profile";
import {Header} from "./Components/Shared/Header"; import { Header } from "./Components/Shared/Header";
import {Donate} from "./Pages/Donate"; import { Donate } from "./Pages/Donate";
import {VoidApi} from "@void-cat/api"; import { VoidApi } from "@void-cat/api";
import {ApiHost} from "./Const"; import { ApiHost } from "./Const";
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
element: <AppLayout/>, element: <AppLayout />,
children: [ children: [
{ {
path: "/", path: "/",
element: <HomePage/> element: <HomePage />,
}, },
{ {
path: "/login", path: "/login",
element: <UserLogin/> element: <UserLogin />,
}, },
{ {
path: "/u/:id", path: "/u/:id",
loader: async ({params}: LoaderFunctionArgs) => { loader: async ({ params }: LoaderFunctionArgs) => {
const api = new VoidApi(ApiHost, store.getState().login.jwt); const state = store.getState();
if(params.id) { const api = new VoidApi(ApiHost, state.login.jwt ? () => Promise.resolve(`Bearer ${state.login.jwt}`) : undefined);
return await api.getUser(params.id); if (params.id) {
} try {
return null; return await api.getUser(params.id);
}, } catch (e) {
element: <ProfilePage/> console.error(e);
},
{
path: "/admin",
element: <Admin/>
},
{
path: "/:id",
element: <FilePreview/>
},
{
path: "/donate",
element: <Donate/>
} }
] }
} return null;
]) },
element: <ProfilePage />,
},
{
path: "/admin",
element: <Admin />,
},
{
path: "/:id",
element: <FilePreview />,
},
{
path: "/donate",
element: <Donate />,
},
],
},
]);
export function AppLayout() { export function AppLayout() {
return ( return (
<div className="app"> <div className="app">
<Provider store={store}> <Provider store={store}>
<Header/> <Header />
<Outlet/> <Outlet />
</Provider> </Provider>
</div> </div>
); );
} }
export default function App() { export default function App() {
return <RouterProvider router={router}/> return <RouterProvider router={router} />;
} }

View File

@ -1,15 +1,15 @@
.file-edit { .file-edit {
text-align: start; text-align: start;
margin: 0 10px; margin: 0 10px;
} }
.file-edit svg { .file-edit svg {
vertical-align: middle; vertical-align: middle;
margin-left: 10px; margin-left: 10px;
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.file-edit { .file-edit {
flex-direction: column; flex-direction: column;
} }
} }

View File

@ -1,105 +1,148 @@
import "./FileEdit.css"; import "./FileEdit.css";
import {useState} from "react"; import { useState } from "react";
import {useSelector} from "react-redux"; import { useSelector } from "react-redux";
import moment from "moment"; import moment from "moment";
import {PaymentServices, SetPaymentConfigRequest, VoidFileResponse} from "@void-cat/api"; import {
PaymentServices,
SetPaymentConfigRequest,
VoidFileResponse,
} from "@void-cat/api";
import {StrikePaymentConfig} from "./StrikePaymentConfig"; import { StrikePaymentConfig } from "./StrikePaymentConfig";
import {NoPaymentConfig} from "./NoPaymentConfig"; import { NoPaymentConfig } from "./NoPaymentConfig";
import {VoidButton} from "../Shared/VoidButton"; import { VoidButton } from "../Shared/VoidButton";
import useApi from "Hooks/UseApi"; import useApi from "Hooks/UseApi";
import {RootState} from "Store"; import { RootState } from "Store";
interface FileEditProps { interface FileEditProps {
file: VoidFileResponse file: VoidFileResponse;
} }
export function FileEdit({file}: FileEditProps) { export function FileEdit({ file }: FileEditProps) {
const Api = useApi(); const Api = useApi();
const profile = useSelector((s: RootState) => s.login.profile); const profile = useSelector((s: RootState) => s.login.profile);
const [payment, setPayment] = useState(file.payment?.service); const [payment, setPayment] = useState(file.payment?.service);
const [name, setName] = useState(file.metadata?.name ?? ""); const [name, setName] = useState(file.metadata?.name ?? "");
const [description, setDescription] = useState(file.metadata?.description ?? ""); const [description, setDescription] = useState(
const [expiry, setExpiry] = useState<number | undefined>(file.metadata?.expires ? moment(file.metadata?.expires).unix() * 1000 : undefined); file.metadata?.description ?? "",
);
const [expiry, setExpiry] = useState<number | undefined>(
file.metadata?.expires
? moment(file.metadata?.expires).unix() * 1000
: undefined,
);
const localFile = window.localStorage.getItem(file.id); const localFile = window.localStorage.getItem(file.id);
const privateFile: VoidFileResponse = profile?.id === file.uploader?.id const privateFile: VoidFileResponse =
? file profile?.id === file.uploader?.id
: localFile ? JSON.parse(localFile) : undefined; ? file
if (!privateFile?.metadata?.editSecret) { : localFile
return null; ? JSON.parse(localFile)
: undefined;
if (!privateFile?.metadata?.editSecret) {
return null;
}
async function savePaymentConfig(cfg: SetPaymentConfigRequest) {
try {
await Api.setPaymentConfig(file.id, cfg);
return true;
} catch (e) {
console.error(e);
return false;
} }
}
async function savePaymentConfig(cfg: SetPaymentConfigRequest) { async function saveMeta() {
try { const meta = {
await Api.setPaymentConfig(file.id, cfg); name,
return true; description,
} catch (e) { editSecret: privateFile?.metadata?.editSecret,
console.error(e); expires: moment(expiry).toISOString(),
return false; };
} await Api.updateFileMetadata(file.id, meta);
}
function renderPaymentConfig() {
switch (payment) {
case PaymentServices.None: {
return (
<NoPaymentConfig
privateFile={privateFile}
onSaveConfig={savePaymentConfig}
/>
);
}
case PaymentServices.Strike: {
return (
<StrikePaymentConfig
file={file}
privateFile={privateFile}
onSaveConfig={savePaymentConfig}
/>
);
}
} }
return null;
}
async function saveMeta() { return (
const meta = { <div className="file-edit flex">
name, <div className="flx-1">
description, <h3>File info</h3>
editSecret: privateFile?.metadata?.editSecret, <dl>
expires: moment(expiry).toISOString() <dt>Filename:</dt>
}; <dd>
await Api.updateFileMetadata(file.id, meta); <input
} type="text"
value={name}
function renderPaymentConfig() { onChange={(e) => setName(e.target.value)}
switch (payment) { />
case PaymentServices.None: { </dd>
return <NoPaymentConfig privateFile={privateFile} onSaveConfig={savePaymentConfig}/>; <dt>Description:</dt>
} <dd>
case PaymentServices.Strike: { <input
return <StrikePaymentConfig file={file} privateFile={privateFile} onSaveConfig={savePaymentConfig}/> type="text"
} value={description}
} onChange={(e) => setDescription(e.target.value)}
return null; />
} </dd>
<dt>Expiry</dt>
return ( <dd>
<div className="file-edit flex"> <input
<div className="flx-1"> type="datetime-local"
<h3>File info</h3> value={
<dl> expiry ? moment(expiry).toISOString().replace("Z", "") : ""
<dt>Filename:</dt> }
<dd><input type="text" value={name} onChange={(e) => setName(e.target.value)}/></dd> max={moment.utc().add(1, "year").toISOString().replace("Z", "")}
<dt>Description:</dt> min={moment.utc().toISOString().replace("Z", "")}
<dd><input type="text" value={description} onChange={(e) => setDescription(e.target.value)}/></dd> onChange={(e) => {
<dt>Expiry</dt> if (e.target.value.length > 0) {
<dd> setExpiry(moment.utc(e.target.value).unix() * 1000);
<input type="datetime-local" } else {
value={expiry ? moment(expiry).toISOString().replace("Z", "") : ""} setExpiry(undefined);
max={moment.utc().add(1, "year").toISOString().replace("Z", "")} }
min={moment.utc().toISOString().replace("Z", "")} }}
onChange={(e) => { />
if (e.target.value.length > 0) { </dd>
setExpiry(moment.utc(e.target.value).unix() * 1000); </dl>
} else { <VoidButton onClick={() => saveMeta()} options={{ showSuccess: true }}>
setExpiry(undefined); Save
} </VoidButton>
}}/> </div>
</dd> <div className="flx-1">
</dl> <h3>Payment Config</h3>
<VoidButton onClick={() => saveMeta()} options={{showSuccess: true}}> Type:
Save <select
</VoidButton> onChange={(e) => setPayment(parseInt(e.target.value))}
</div> value={payment}
<div className="flx-1"> >
<h3>Payment Config</h3> <option value={0}>None</option>
Type: <option value={1}>Strike</option>
<select onChange={(e) => setPayment(parseInt(e.target.value))} value={payment}> </select>
<option value={0}>None</option> {renderPaymentConfig()}
<option value={1}>Strike</option> </div>
</select> </div>
{renderPaymentConfig()} );
</div>
</div>
);
} }

View File

@ -1,29 +1,36 @@
import React from "react"; import React from "react";
import {VoidButton} from "../Shared/VoidButton"; import { VoidButton } from "../Shared/VoidButton";
import {PaymentCurrencies, SetPaymentConfigRequest, VoidFileResponse} from "@void-cat/api"; import {
PaymentCurrencies,
SetPaymentConfigRequest,
VoidFileResponse,
} from "@void-cat/api";
interface NoPaymentConfigProps { interface NoPaymentConfigProps {
privateFile: VoidFileResponse privateFile: VoidFileResponse;
onSaveConfig: (c: SetPaymentConfigRequest) => Promise<any> onSaveConfig: (c: SetPaymentConfigRequest) => Promise<any>;
} }
export function NoPaymentConfig({privateFile, onSaveConfig}: NoPaymentConfigProps) { export function NoPaymentConfig({
async function saveConfig() { privateFile,
const cfg = { onSaveConfig,
editSecret: privateFile.metadata!.editSecret, }: NoPaymentConfigProps) {
required: false, async function saveConfig() {
amount: 0, const cfg = {
currency: PaymentCurrencies.BTC editSecret: privateFile.metadata!.editSecret,
} as SetPaymentConfigRequest; required: false,
amount: 0,
currency: PaymentCurrencies.BTC,
} as SetPaymentConfigRequest;
await onSaveConfig(cfg) await onSaveConfig(cfg);
} }
return ( return (
<div> <div>
<VoidButton onClick={saveConfig} options={{showSuccess: true}}> <VoidButton onClick={saveConfig} options={{ showSuccess: true }}>
Save Save
</VoidButton> </VoidButton>
</div> </div>
) );
} }

View File

@ -1,57 +1,88 @@
import {useState} from "react"; import { useState } from "react";
import {VoidButton} from "../Shared/VoidButton"; import { VoidButton } from "../Shared/VoidButton";
import {PaymentCurrencies, SetPaymentConfigRequest, VoidFileResponse} from "@void-cat/api"; import {
PaymentCurrencies,
SetPaymentConfigRequest,
VoidFileResponse,
} from "@void-cat/api";
interface StrikePaymentConfigProps { interface StrikePaymentConfigProps {
file: VoidFileResponse file: VoidFileResponse;
privateFile: VoidFileResponse privateFile: VoidFileResponse;
onSaveConfig: (cfg: SetPaymentConfigRequest) => Promise<any> onSaveConfig: (cfg: SetPaymentConfigRequest) => Promise<any>;
} }
export function StrikePaymentConfig({file, privateFile, onSaveConfig}: StrikePaymentConfigProps) { export function StrikePaymentConfig({
const payment = file.payment; file,
const editSecret = privateFile.metadata!.editSecret; privateFile,
onSaveConfig,
}: StrikePaymentConfigProps) {
const payment = file.payment;
const editSecret = privateFile.metadata!.editSecret;
const [username, setUsername] = useState(payment?.strikeHandle ?? "hrf"); const [username, setUsername] = useState(payment?.strikeHandle ?? "hrf");
const [currency, setCurrency] = useState(payment?.currency ?? PaymentCurrencies.USD); const [currency, setCurrency] = useState(
const [price, setPrice] = useState(payment?.amount ?? 1); payment?.currency ?? PaymentCurrencies.USD,
const [required, setRequired] = useState(payment?.required); );
const [price, setPrice] = useState(payment?.amount ?? 1);
const [required, setRequired] = useState(payment?.required);
async function saveStrikeConfig() { async function saveStrikeConfig() {
const cfg = { const cfg = {
editSecret, editSecret,
strikeHandle: username, strikeHandle: username,
currency, currency,
amount: price, amount: price,
required required,
} as SetPaymentConfigRequest; } as SetPaymentConfigRequest;
await onSaveConfig(cfg) await onSaveConfig(cfg);
} }
return ( return (
<div> <div>
<dl> <dl>
<dt>Strike username:</dt> <dt>Strike username:</dt>
<dd><input type="text" value={username} onChange={(e) => setUsername(e.target.value)}/></dd> <dd>
<dt>Currency:</dt> <input
<dd> type="text"
<select onChange={(e) => setCurrency(parseInt(e.target.value))} value={currency}> value={username}
<option value={PaymentCurrencies.BTC}>BTC</option> onChange={(e) => setUsername(e.target.value)}
<option value={PaymentCurrencies.USD}>USD</option> />
<option value={PaymentCurrencies.EUR}>EUR</option> </dd>
<option value={PaymentCurrencies.GBP}>GBP</option> <dt>Currency:</dt>
</select> <dd>
</dd> <select
<dt>Price:</dt> onChange={(e) => setCurrency(parseInt(e.target.value))}
<dd><input type="number" value={price} onChange={(e) => setPrice(Number(e.target.value))}/></dd> value={currency}
<dt>Required:</dt> >
<dd><input type="checkbox" checked={required} onChange={(e) => setRequired(e.target.checked)}/></dd> <option value={PaymentCurrencies.BTC}>BTC</option>
</dl> <option value={PaymentCurrencies.USD}>USD</option>
<VoidButton onClick={saveStrikeConfig} options={{showSuccess: true}}> <option value={PaymentCurrencies.EUR}>EUR</option>
Save <option value={PaymentCurrencies.GBP}>GBP</option>
</VoidButton> </select>
</div> </dd>
); <dt>Price:</dt>
<dd>
<input
type="number"
value={price}
onChange={(e) => setPrice(Number(e.target.value))}
/>
</dd>
<dt>Required:</dt>
<dd>
<input
type="checkbox"
checked={required}
onChange={(e) => setRequired(e.target.checked)}
/>
</dd>
</dl>
<VoidButton onClick={saveStrikeConfig} options={{ showSuccess: true }}>
Save
</VoidButton>
</div>
);
} }

View File

@ -1,7 +1,7 @@
.payment { .payment {
text-align: center; text-align: center;
border: 1px solid lightgreen; border: 1px solid lightgreen;
padding: 10px; padding: 10px;
margin: 10px; margin: 10px;
border-radius: 10px; border-radius: 10px;
} }

View File

@ -1,67 +1,73 @@
import "./FilePayment.css"; import "./FilePayment.css";
import {useState} from "react"; import { useState } from "react";
import {LightningPayment} from "./LightningPayment"; import { LightningPayment } from "./LightningPayment";
import {VoidButton} from "../Shared/VoidButton"; import { VoidButton } from "../Shared/VoidButton";
import {PaymentOrder, PaymentServices, VoidFileResponse} from "@void-cat/api"; import { PaymentOrder, PaymentServices, VoidFileResponse } from "@void-cat/api";
import useApi from "Hooks/UseApi"; import useApi from "Hooks/UseApi";
import {FormatCurrency} from "Util"; import { FormatCurrency } from "Util";
interface FilePaymentProps { interface FilePaymentProps {
file: VoidFileResponse file: VoidFileResponse;
onPaid: () => Promise<void> onPaid: () => Promise<void>;
} }
export function FilePayment({file, onPaid}: FilePaymentProps) { export function FilePayment({ file, onPaid }: FilePaymentProps) {
const Api = useApi(); const Api = useApi();
const paymentKey = `payment-${file.id}`; const paymentKey = `payment-${file.id}`;
const [order, setOrder] = useState<any>(); const [order, setOrder] = useState<any>();
// Payment not required // Payment not required
if (!file.payment) return null; if (!file.payment) return null;
async function fetchOrder() { async function fetchOrder() {
try { try {
const rsp = await Api.createOrder(file.id); const rsp = await Api.createOrder(file.id);
setOrder(rsp); setOrder(rsp);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
}
} }
}
function reset() { function reset() {
setOrder(undefined); setOrder(undefined);
}
function handlePaid(order: PaymentOrder) {
window.localStorage.setItem(paymentKey, JSON.stringify(order));
if (typeof onPaid === "function") {
onPaid();
} }
}
function handlePaid(order: PaymentOrder) { if (!order) {
window.localStorage.setItem(paymentKey, JSON.stringify(order)); const amountString = FormatCurrency(
if (typeof onPaid === "function") { file.payment.amount,
onPaid(); file.payment.currency,
} );
} if (file.payment.required) {
return (
if (!order) { <div className="payment">
const amountString = FormatCurrency(file.payment.amount, file.payment.currency); <h3>You must pay {amountString} to view this file.</h3>
if (file.payment.required) { <VoidButton onClick={fetchOrder}>Pay</VoidButton>
return ( </div>
<div className="payment"> );
<h3>
You must pay {amountString} to view this file.
</h3>
<VoidButton onClick={fetchOrder}>Pay</VoidButton>
</div>
);
} else {
return (
<VoidButton onClick={fetchOrder}>Tip {amountString}</VoidButton>
);
}
} else { } else {
switch (file.payment.service) { return <VoidButton onClick={fetchOrder}>Tip {amountString}</VoidButton>;
case PaymentServices.Strike: {
return <LightningPayment file={file} order={order} onReset={reset} onPaid={handlePaid}/>;
}
}
} }
return null; } else {
switch (file.payment.service) {
case PaymentServices.Strike: {
return (
<LightningPayment
file={file}
order={order}
onReset={reset}
onPaid={handlePaid}
/>
);
}
}
}
return null;
} }

View File

@ -1,51 +1,59 @@
import QRCode from "qrcode.react"; import QRCode from "qrcode.react";
import {useEffect} from "react"; import { useEffect } from "react";
import {PaymentOrder, PaymentOrderState, VoidFileResponse} from "@void-cat/api"; import {
PaymentOrder,
PaymentOrderState,
VoidFileResponse,
} from "@void-cat/api";
import {Countdown} from "../Shared/Countdown"; import { Countdown } from "../Shared/Countdown";
import useApi from "Hooks/UseApi"; import useApi from "Hooks/UseApi";
interface LightningPaymentProps { interface LightningPaymentProps {
file: VoidFileResponse file: VoidFileResponse;
order: PaymentOrder order: PaymentOrder;
onPaid: (s: PaymentOrder) => void onPaid: (s: PaymentOrder) => void;
onReset: () => void onReset: () => void;
} }
export function LightningPayment({file, order, onPaid, onReset}: LightningPaymentProps) { export function LightningPayment({
const Api = useApi(); file,
const link = `lightning:${order.orderLightning?.invoice}`; order,
onPaid,
onReset,
}: LightningPaymentProps) {
const Api = useApi();
const link = `lightning:${order.orderLightning?.invoice}`;
function openInvoice() { function openInvoice() {
const a = document.createElement("a"); const a = document.createElement("a");
a.href = link; a.href = link;
a.click(); a.click();
}
async function checkStatus() {
const os = await Api.getOrder(file.id, order.id);
if (os.status === PaymentOrderState.Paid && typeof onPaid === "function") {
onPaid(os);
} }
}
async function checkStatus() { useEffect(() => {
const os = await Api.getOrder(file.id, order.id); let t = setInterval(checkStatus, 2500);
if (os.status === PaymentOrderState.Paid && typeof onPaid === "function") { return () => clearInterval(t);
onPaid(os); }, []);
}
}
useEffect(() => { return (
let t = setInterval(checkStatus, 2500); <div className="lightning-invoice" onClick={openInvoice}>
return () => clearInterval(t); <h1>Pay with Lightning </h1>
}, []); <QRCode value={link} size={512} includeMargin={true} />
<dl>
return ( <dt>Expires:</dt>
<div className="lightning-invoice" onClick={openInvoice}> <dd>
<h1>Pay with Lightning </h1> <Countdown to={order.orderLightning!.expire} onEnded={onReset} />
<QRCode </dd>
value={link} </dl>
size={512} </div>
includeMargin={true}/> );
<dl>
<dt>Expires:</dt>
<dd><Countdown to={order.orderLightning!.expire} onEnded={onReset}/></dd>
</dl>
</div>
);
} }

View File

@ -1,7 +1,7 @@
.text-preview { .text-preview {
border: 1px dashed; border: 1px dashed;
padding: 10px; padding: 10px;
border-radius: 10px; border-radius: 10px;
text-align: initial; text-align: initial;
overflow: auto; overflow: auto;
} }

View File

@ -1,30 +1,28 @@
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import "./TextPreview.css"; import "./TextPreview.css";
export function TextPreview({link}: { link: string }) { export function TextPreview({ link }: { link: string }) {
let [content, setContent] = useState("Loading.."); let [content, setContent] = useState("Loading..");
async function getContent(link: string) { async function getContent(link: string) {
let req = await fetch(`${link}?t=${new Date().getTime()}`, { let req = await fetch(`${link}?t=${new Date().getTime()}`, {
headers: { headers: {
"pragma": "no-cache", pragma: "no-cache",
"cache-control": "no-cache" "cache-control": "no-cache",
} },
}); });
if (req.ok) { if (req.ok) {
setContent(await req.text()); setContent(await req.text());
} else { } else {
setContent("ERROR :(") setContent("ERROR :(");
}
} }
}
useEffect(() => { useEffect(() => {
if (link !== undefined && link !== "#") { if (link !== undefined && link !== "#") {
getContent(link).catch(console.error); getContent(link).catch(console.error);
} }
}, [link]); }, [link]);
return ( return <pre className="text-preview">{content}</pre>;
<pre className="text-preview">{content}</pre>
)
} }

View File

@ -1,18 +1,18 @@
.drop { .drop {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 20px; border-radius: 20px;
border: 2px dashed; border: 2px dashed;
margin: 5vh 2px 2px; margin: 5vh 2px 2px;
text-align: center; text-align: center;
user-select: none; user-select: none;
cursor: pointer; cursor: pointer;
height: 250px; height: 250px;
font-size: x-large; font-size: x-large;
} }
.drop small { .drop small {
display: block; display: block;
font-size: x-small; font-size: x-small;
} }

View File

@ -1,65 +1,64 @@
import "./Dropzone.css"; import "./Dropzone.css";
import {Fragment, useEffect, useState} from "react"; import { Fragment, useEffect, useState } from "react";
import {FileUpload} from "./FileUpload"; import { FileUpload } from "./FileUpload";
export function Dropzone() { export function Dropzone() {
let [files, setFiles] = useState<Array<File>>([]); let [files, setFiles] = useState<Array<File>>([]);
function selectFiles() { function selectFiles() {
let i = document.createElement('input'); let i = document.createElement("input");
i.setAttribute('type', 'file'); i.setAttribute("type", "file");
i.setAttribute('multiple', ''); i.setAttribute("multiple", "");
i.addEventListener('change', function (evt) { i.addEventListener("change", function (evt) {
if (evt.target && "files" in evt.target) { if (evt.target && "files" in evt.target) {
setFiles(evt.target.files as Array<File>); setFiles(evt.target.files as Array<File>);
} }
}); });
i.click(); i.click();
}
function dropFiles(e: DragEvent | ClipboardEvent) {
e.preventDefault();
e.stopPropagation();
if ("dataTransfer" in e && (e.dataTransfer?.files?.length ?? 0) > 0) {
setFiles([...e.dataTransfer!.files]);
} else if (
"clipboardData" in e &&
(e.clipboardData?.files?.length ?? 0) > 0
) {
setFiles([...e.clipboardData!.files]);
} }
}
function dropFiles(e: DragEvent | ClipboardEvent) { function renderUploads() {
e.preventDefault(); let fElm = [];
e.stopPropagation(); for (let f of files) {
if ("dataTransfer" in e && (e.dataTransfer?.files?.length ?? 0) > 0) { fElm.push(<FileUpload file={f} key={f.name} />);
setFiles([...e.dataTransfer!.files]);
} else if ("clipboardData" in e && (e.clipboardData?.files?.length ?? 0) > 0) {
setFiles([...e.clipboardData!.files]);
}
} }
return <Fragment>{fElm}</Fragment>;
}
function renderUploads() { function renderDrop() {
let fElm = []; return (
for (let f of files) { <div className="drop" onClick={selectFiles}>
fElm.push(<FileUpload file={f} key={f.name}/>); <div>
} Click me!
return ( <small>Or drop files here</small>
<Fragment> </div>
{fElm} </div>
</Fragment> );
); }
}
function renderDrop() { useEffect(() => {
return ( document.addEventListener("paste", dropFiles);
<div className="drop" onClick={selectFiles}> document.addEventListener("drop", dropFiles);
<div> document.addEventListener("dragover", dropFiles);
Click me! return () => {
<small>Or drop files here</small> document.removeEventListener("paste", dropFiles);
</div> document.removeEventListener("drop", dropFiles);
</div> document.removeEventListener("dragover", dropFiles);
); };
} }, []);
useEffect(() => { return files.length === 0 ? renderDrop() : renderUploads();
document.addEventListener("paste", dropFiles);
document.addEventListener("drop", dropFiles);
document.addEventListener("dragover", dropFiles);
return () => {
document.removeEventListener("paste", dropFiles);
document.removeEventListener("drop", dropFiles);
document.removeEventListener("dragover", dropFiles);
};
}, []);
return files.length === 0 ? renderDrop() : renderUploads();
} }

View File

@ -1,41 +1,41 @@
.upload { .upload {
display: flex; display: flex;
padding: 10px; padding: 10px;
border: 1px solid grey; border: 1px solid grey;
border-radius: 10px; border-radius: 10px;
margin-top: 10px; margin-top: 10px;
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.upload { .upload {
flex-direction: column; flex-direction: column;
} }
} }
.upload .info { .upload .info {
flex: 2; flex: 2;
} }
.upload .status { .upload .status {
flex: 1; flex: 1;
} }
.upload dt { .upload dt {
font-size: 12px; font-size: 12px;
color: grey; color: grey;
} }
.upload .iframe-challenge { .upload .iframe-challenge {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background-color: rgba(0,0,0,0.8); background-color: rgba(0, 0, 0, 0.8);
} }
.upload .iframe-challenge iframe { .upload .iframe-challenge iframe {
margin-left: 10vw; margin-left: 10vw;
width: 80vw; width: 80vw;
height: 100vh; height: 100vh;
} }

View File

@ -1,131 +1,159 @@
import "./FileUpload.css"; import "./FileUpload.css";
import {useEffect, useMemo, useState} from "react"; import { useEffect, useMemo, useState } from "react";
import {useSelector} from "react-redux"; import { useSelector } from "react-redux";
import {UploadState, VoidFileResponse} from "@void-cat/api"; import { UploadState, VoidFileResponse } from "@void-cat/api";
import {VoidButton} from "../Shared/VoidButton"; import { VoidButton } from "../Shared/VoidButton";
import {useFileTransfer} from "../Shared/FileTransferHook"; import { useFileTransfer } from "../Shared/FileTransferHook";
import {RootState} from "Store"; import { RootState } from "Store";
import {ConstName, FormatBytes} from "Util"; import { ConstName, FormatBytes } from "Util";
import useApi from "Hooks/UseApi"; import useApi from "Hooks/UseApi";
interface FileUploadProps { interface FileUploadProps {
file: File | Blob file: File | Blob;
} }
export function FileUpload({file}: FileUploadProps) { export function FileUpload({ file }: FileUploadProps) {
const info = useSelector((s: RootState) => s.info.info); const info = useSelector((s: RootState) => s.info.info);
const {speed, progress, loaded, setFileSize, reset} = useFileTransfer(); const { speed, progress, loaded, setFileSize, reset } = useFileTransfer();
const Api = useApi(); const Api = useApi();
const [result, setResult] = useState<VoidFileResponse>(); const [result, setResult] = useState<VoidFileResponse>();
const [error, setError] = useState(""); const [error, setError] = useState("");
const [uState, setUState] = useState(UploadState.NotStarted); const [uState, setUState] = useState(UploadState.NotStarted);
const [challenge, setChallenge] = useState(""); const [challenge, setChallenge] = useState("");
const [encryptionKey, setEncryptionKey] = useState(""); const [encryptionKey, setEncryptionKey] = useState("");
const [encrypt, setEncrypt] = useState(true); const [encrypt, setEncrypt] = useState(true);
const uploader = useMemo(() => { const uploader = useMemo(() => {
return Api.getUploader(file, setUState, loaded, setChallenge, info?.uploadSegmentSize); return Api.getUploader(
}, [Api, file]); file,
setUState,
useEffect(() => { loaded,
uploader.setEncryption(encrypt); setChallenge,
}, [uploader, encrypt]); info?.uploadSegmentSize,
useEffect(() => {
reset();
setFileSize(file.size);
if (!uploader.canEncrypt() && uState === UploadState.NotStarted) {
startUpload().catch(console.error)
}
}, [file, uploader, uState]);
async function startUpload() {
setUState(UploadState.Starting);
try {
const result = await uploader.upload();
console.debug(result);
if (result.ok) {
setUState(UploadState.Done);
setResult(result.file);
setEncryptionKey(uploader.getEncryptionKey() ?? "");
window.localStorage.setItem(result.file!.id, JSON.stringify(result.file!));
} else {
setUState(UploadState.Failed);
setError(result.errorMessage!);
}
} catch (e) {
setUState(UploadState.Failed);
if (e instanceof Error) {
setError(e.message);
} else {
setError("Unknown error");
}
}
}
function renderStatus() {
if (result && uState === UploadState.Done) {
let link = `/${result.id}`;
return (<dl>
<dt>Link:</dt>
<dd><a target="_blank" href={link} rel="noreferrer">{result.id}</a></dd>
{encryptionKey ? <>
<dt>Encryption Key:</dt>
<dd>
<VoidButton onClick={() => navigator.clipboard.writeText(encryptionKey)}>Copy</VoidButton>
</dd>
</> : null}
</dl>)
} else if (uState === UploadState.NotStarted) {
return (
<>
<dl>
<dt>Encrypt file:</dt>
<dd><input type="checkbox" checked={encrypt} onChange={(e) => setEncrypt(e.target.checked)}/>
</dd>
</dl>
<VoidButton onClick={() => startUpload()}>Upload</VoidButton>
</>
)
} else {
return (
<dl>
<dt>Speed:</dt>
<dd>{FormatBytes(speed)}/s</dd>
<dt>Progress:</dt>
<dd>{(progress * 100).toFixed(0)}%</dd>
<dt>Status:</dt>
<dd>{error ? error : ConstName(UploadState, uState)}</dd>
</dl>
);
}
}
function getChallengeElement() {
let elm = document.createElement("iframe");
elm.contentWindow?.document.write(challenge);
return <div dangerouslySetInnerHTML={{__html: elm.outerHTML}}/>;
}
return (
<div className="upload">
<div className="info">
<dl>
<dt>Name:</dt>
<dd>{file.name}</dd>
<dt>Size:</dt>
<dd>{FormatBytes(file.size)}</dd>
</dl>
</div>
<div className="status">
{renderStatus()}
</div>
{uState === UploadState.Challenge &&
<div className="iframe-challenge" onClick={() => window.location.reload()}>
{getChallengeElement()}
</div>}
</div>
); );
}, [Api, file]);
useEffect(() => {
uploader.setEncryption(encrypt);
}, [uploader, encrypt]);
useEffect(() => {
reset();
setFileSize(file.size);
if (!uploader.canEncrypt() && uState === UploadState.NotStarted) {
startUpload().catch(console.error);
}
}, [file, uploader, uState]);
async function startUpload() {
setUState(UploadState.Starting);
try {
const result = await uploader.upload();
console.debug(result);
if (result.ok) {
setUState(UploadState.Done);
setResult(result.file);
setEncryptionKey(uploader.getEncryptionKey() ?? "");
window.localStorage.setItem(
result.file!.id,
JSON.stringify(result.file!),
);
} else {
setUState(UploadState.Failed);
setError(result.errorMessage!);
}
} catch (e) {
setUState(UploadState.Failed);
if (e instanceof Error) {
setError(e.message);
} else {
setError("Unknown error");
}
}
}
function renderStatus() {
if (result && uState === UploadState.Done) {
let link = `/${result.id}`;
return (
<dl>
<dt>Link:</dt>
<dd>
<a target="_blank" href={link} rel="noreferrer">
{result.id}
</a>
</dd>
{encryptionKey ? (
<>
<dt>Encryption Key:</dt>
<dd>
<VoidButton
onClick={() => navigator.clipboard.writeText(encryptionKey)}
>
Copy
</VoidButton>
</dd>
</>
) : null}
</dl>
);
} else if (uState === UploadState.NotStarted) {
return (
<>
<dl>
<dt>Encrypt file:</dt>
<dd>
<input
type="checkbox"
checked={encrypt}
onChange={(e) => setEncrypt(e.target.checked)}
/>
</dd>
</dl>
<VoidButton onClick={() => startUpload()}>Upload</VoidButton>
</>
);
} else {
return (
<dl>
<dt>Speed:</dt>
<dd>{FormatBytes(speed)}/s</dd>
<dt>Progress:</dt>
<dd>{(progress * 100).toFixed(0)}%</dd>
<dt>Status:</dt>
<dd>{error ? error : ConstName(UploadState, uState)}</dd>
</dl>
);
}
}
function getChallengeElement() {
let elm = document.createElement("iframe");
elm.contentWindow?.document.write(challenge);
return <div dangerouslySetInnerHTML={{ __html: elm.outerHTML }} />;
}
return (
<div className="upload">
<div className="info">
<dl>
<dt>Name:</dt>
<dd>{file.name}</dd>
<dt>Size:</dt>
<dd>{FormatBytes(file.size)}</dd>
</dl>
</div>
<div className="status">{renderStatus()}</div>
{uState === UploadState.Challenge && (
<div
className="iframe-challenge"
onClick={() => window.location.reload()}
>
{getChallengeElement()}
</div>
)}
</div>
);
} }

View File

@ -1,20 +1,20 @@
.footer { .footer {
margin-top: 15px; margin-top: 15px;
text-align: center; text-align: center;
} }
.footer > a { .footer > a {
margin-left: 10px; margin-left: 10px;
padding-right: 10px; padding-right: 10px;
border-right: 1px solid; border-right: 1px solid;
} }
.footer > a:last-child { .footer > a:last-child {
border: none; border: none;
} }
.footer > a > img { .footer > a > img {
filter: invert(1); filter: invert(1);
vertical-align: middle; vertical-align: middle;
height: 20px; height: 20px;
} }

View File

@ -1,22 +1,26 @@
import "./FooterLinks.css" import "./FooterLinks.css";
import {useSelector} from "react-redux"; import { useSelector } from "react-redux";
import {Link} from "react-router-dom"; import { Link } from "react-router-dom";
import {RootState} from "Store"; import { RootState } from "Store";
export function FooterLinks() { export function FooterLinks() {
const profile = useSelector((s:RootState) => s.login.profile); const profile = useSelector((s: RootState) => s.login.profile);
return ( return (
<div className="footer"> <div className="footer">
<a href="https://discord.gg/8BkxTGs" target="_blank" rel="noreferrer"> <a href="https://discord.gg/8BkxTGs" target="_blank" rel="noreferrer">
Discord Discord
</a> </a>
<a href="https://git.v0l.io/Kieran/void.cat/" target="_blank" rel="noreferrer"> <a
GitHub href="https://git.v0l.io/Kieran/void.cat/"
</a> target="_blank"
<Link to="/donate">Donate</Link> rel="noreferrer"
{profile?.roles?.includes("Admin") ? <a href="/admin">Admin</a> : null} >
</div> GitHub
); </a>
<Link to="/donate">Donate</Link>
{profile?.roles?.includes("Admin") ? <a href="/admin">Admin</a> : null}
</div>
);
} }

View File

@ -1,41 +1,41 @@
.stats { .stats {
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: column;
margin: 0 30px; margin: 0 30px;
line-height: 32px; line-height: 32px;
text-align: center; text-align: center;
} }
.stats svg { .stats svg {
vertical-align: middle; vertical-align: middle;
margin-right: 10px; margin-right: 10px;
} }
.stats > div { .stats > div {
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.stats { .stats {
margin: 0 5px; margin: 0 5px;
font-size: 14px; font-size: 14px;
} }
.stats svg { .stats svg {
height: 16px; height: 16px;
width: 16px; width: 16px;
} }
.build-info { .build-info {
width: 100vw; width: 100vw;
} }
} }
.build-info { .build-info {
position: fixed; position: fixed;
left: 0; left: 0;
bottom: 0; bottom: 0;
color: #888; color: #888;
text-align: center; text-align: center;
font-size: x-small; font-size: x-small;
padding: 10px; padding: 10px;
} }

View File

@ -1,40 +1,42 @@
import "./GlobalStats.css"; import "./GlobalStats.css";
import {Fragment} from "react"; import { Fragment } from "react";
import moment from "moment"; import moment from "moment";
import {useSelector} from "react-redux"; import { useSelector } from "react-redux";
import Icon from "Components/Shared/Icon"; import Icon from "Components/Shared/Icon";
import {RootState} from "Store"; import { RootState } from "Store";
import {FormatBytes} from "Util"; import { FormatBytes } from "Util";
export function GlobalStats() { export function GlobalStats() {
let stats = useSelector((s: RootState) => s.info.info); let stats = useSelector((s: RootState) => s.info.info);
return ( return (
<Fragment> <Fragment>
<dl className="stats"> <dl className="stats">
<div> <div>
<Icon name="upload-cloud"/> <Icon name="upload-cloud" />
{FormatBytes(stats?.bandwidth?.ingress ?? 0, 2)} {FormatBytes(stats?.bandwidth?.ingress ?? 0, 2)}
</div> </div>
<div> <div>
<Icon name="download-cloud"/> <Icon name="download-cloud" />
{FormatBytes(stats?.bandwidth?.egress ?? 0, 2)} {FormatBytes(stats?.bandwidth?.egress ?? 0, 2)}
</div> </div>
<div> <div>
<Icon name="save"/> <Icon name="save" />
{FormatBytes(stats?.totalBytes ?? 0, 2)} {FormatBytes(stats?.totalBytes ?? 0, 2)}
</div> </div>
<div> <div>
<Icon name="hash"/> <Icon name="hash" />
{stats?.count ?? 0} {stats?.count ?? 0}
</div> </div>
</dl> </dl>
{stats?.buildInfo && <div className="build-info"> {stats?.buildInfo && (
{stats.buildInfo.version}-{stats.buildInfo.gitHash} <div className="build-info">
<br/> {stats.buildInfo.version}-{stats.buildInfo.gitHash}
{moment(stats.buildInfo.buildTime).fromNow()} <br />
</div>} {moment(stats.buildInfo.buildTime).fromNow()}
</Fragment> </div>
); )}
</Fragment>
);
} }

View File

@ -1,26 +1,34 @@
import {Bar, BarChart, Tooltip, XAxis} from "recharts"; import { Bar, BarChart, Tooltip, XAxis } from "recharts";
import moment from "moment"; import moment from "moment";
import {BandwidthPoint} from "@void-cat/api"; import { BandwidthPoint } from "@void-cat/api";
import {FormatBytes} from "Util"; import { FormatBytes } from "Util";
interface MetricsGraphProps { interface MetricsGraphProps {
metrics?: Array<BandwidthPoint> metrics?: Array<BandwidthPoint>;
} }
export function MetricsGraph({metrics}: MetricsGraphProps) { export function MetricsGraph({ metrics }: MetricsGraphProps) {
if (!metrics || metrics.length === 0) return null; if (!metrics || metrics.length === 0) return null;
return ( return (
<BarChart <BarChart
width={Math.min(window.innerWidth, 900)} width={Math.min(window.innerWidth, 900)}
height={200} height={200}
data={metrics} data={metrics}
margin={{left: 0, right: 0}} margin={{ left: 0, right: 0 }}
style={{userSelect: "none"}}> style={{ userSelect: "none" }}
<XAxis dataKey="time" tickFormatter={(v) => `${moment(v).format("DD-MMM")}`}/> >
<Bar dataKey="egress" fill="#ccc"/> <XAxis
<Tooltip formatter={(v) => FormatBytes(v as number)} labelStyle={{color: "#aaa"}} itemStyle={{color: "#eee"}} dataKey="time"
contentStyle={{backgroundColor: "#111"}}/> tickFormatter={(v) => `${moment(v).format("DD-MMM")}`}
</BarChart> />
); <Bar dataKey="egress" fill="#ccc" />
<Tooltip
formatter={(v) => FormatBytes(v as number)}
labelStyle={{ color: "#aaa" }}
itemStyle={{ color: "#eee" }}
contentStyle={{ backgroundColor: "#111" }}
/>
</BarChart>
);
} }

View File

@ -1,83 +1,86 @@
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import moment from "moment"; import moment from "moment";
import {ApiKey} from "@void-cat/api"; import { ApiKey } from "@void-cat/api";
import {VoidButton} from "../Shared/VoidButton"; import { VoidButton } from "../Shared/VoidButton";
import VoidModal from "../Shared/VoidModal"; import VoidModal from "../Shared/VoidModal";
import useApi from "Hooks/UseApi"; import useApi from "Hooks/UseApi";
export default function ApiKeyList() { export default function ApiKeyList() {
const Api = useApi(); const Api = useApi();
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]); const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [newApiKey, setNewApiKey] = useState<ApiKey>(); const [newApiKey, setNewApiKey] = useState<ApiKey>();
const DefaultExpiry = 1000 * 60 * 60 * 24 * 90; const DefaultExpiry = 1000 * 60 * 60 * 24 * 90;
async function loadApiKeys() { async function loadApiKeys() {
try { try {
const keys = await Api.listApiKeys(); const keys = await Api.listApiKeys();
setApiKeys(keys); setApiKeys(keys);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
}
} }
}
async function createApiKey() { async function createApiKey() {
try { try {
const rsp = await Api.createApiKey({ const rsp = await Api.createApiKey({
expiry: new Date(new Date().getTime() + DefaultExpiry) expiry: new Date(new Date().getTime() + DefaultExpiry),
}); });
setNewApiKey(rsp); setNewApiKey(rsp);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
}
} }
}
function openDocs() { function openDocs() {
window.open("/swagger", "_blank") window.open("/swagger", "_blank");
} }
useEffect(() => { useEffect(() => {
loadApiKeys().catch(console.error); loadApiKeys().catch(console.error);
}, []); }, []);
return ( return (
<> <>
<div className="flex flex-center"> <div className="flex flex-center">
<div className="flx-grow"> <div className="flx-grow">
<h1>API Keys</h1> <h1>API Keys</h1>
</div> </div>
<div> <div>
<VoidButton onClick={() => createApiKey()}>+New</VoidButton> <VoidButton onClick={() => createApiKey()}>+New</VoidButton>
<VoidButton onClick={() => openDocs()}>Docs</VoidButton> <VoidButton onClick={() => openDocs()}>Docs</VoidButton>
</div> </div>
</div> </div>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Id</th> <th>Id</th>
<th>Created</th> <th>Created</th>
<th>Expiry</th> <th>Expiry</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{apiKeys.map(e => <tr key={e.id}> {apiKeys.map((e) => (
<td>{e.id}</td> <tr key={e.id}>
<td>{moment(e.created).fromNow()}</td> <td>{e.id}</td>
<td>{moment(e.expiry).fromNow()}</td> <td>{moment(e.created).fromNow()}</td>
<td> <td>{moment(e.expiry).fromNow()}</td>
<VoidButton>Delete</VoidButton> <td>
</td> <VoidButton>Delete</VoidButton>
</tr>)} </td>
</tbody> </tr>
</table> ))}
{newApiKey && </tbody>
<VoidModal title="New Api Key" style={{maxWidth: "50vw"}}> </table>
Please save this now as it will not be shown again: {newApiKey && (
<pre className="copy">{newApiKey.token}</pre> <VoidModal title="New Api Key" style={{ maxWidth: "50vw" }}>
<VoidButton onClick={() => setNewApiKey(undefined)}>Close</VoidButton> Please save this now as it will not be shown again:
</VoidModal>} <pre className="copy">{newApiKey.token}</pre>
</> <VoidButton onClick={() => setNewApiKey(undefined)}>Close</VoidButton>
); </VoidModal>
)}
</>
);
} }

View File

@ -1,24 +1,24 @@
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
interface CountdownProps { interface CountdownProps {
to: number | string | Date to: number | string | Date;
onEnded?: () => void onEnded?: () => void;
} }
export function Countdown({onEnded, to}: CountdownProps) { export function Countdown({ onEnded, to }: CountdownProps) {
const [time, setTime] = useState(0); const [time, setTime] = useState(0);
useEffect(() => { useEffect(() => {
const t = setInterval(() => { const t = setInterval(() => {
const toDate = new Date(to).getTime(); const toDate = new Date(to).getTime();
const now = new Date().getTime(); const now = new Date().getTime();
const seconds = (toDate - now) / 1000.0; const seconds = (toDate - now) / 1000.0;
setTime(Math.max(0, seconds)); setTime(Math.max(0, seconds));
if (seconds <= 0 && typeof onEnded === "function") { if (seconds <= 0 && typeof onEnded === "function") {
onEnded(); onEnded();
} }
}, 100); }, 100);
return () => clearInterval(t); return () => clearInterval(t);
}, []) }, []);
return <div>{time.toFixed(1)}s</div> return <div>{time.toFixed(1)}s</div>;
} }

View File

@ -1,100 +1,124 @@
import {useDispatch} from "react-redux"; import { useDispatch } from "react-redux";
import {ReactNode, useEffect, useState} from "react"; import { ReactNode, useEffect, useState } from "react";
import {Link} from "react-router-dom"; import { Link } from "react-router-dom";
import moment from "moment"; import moment from "moment";
import {ApiError, PagedRequest, PagedResponse, PagedSortBy, PageSortOrder, VoidFileResponse} from "@void-cat/api"; import {
ApiError,
PagedRequest,
PagedResponse,
PagedSortBy,
PageSortOrder,
VoidFileResponse,
} from "@void-cat/api";
import {logout} from "../../LoginState"; import { logout } from "../../LoginState";
import {PageSelector} from "./PageSelector"; import { PageSelector } from "./PageSelector";
import {FormatBytes} from "Util"; import { FormatBytes } from "Util";
interface FileListProps { interface FileListProps {
actions?: (f: VoidFileResponse) => ReactNode actions?: (f: VoidFileResponse) => ReactNode;
loadPage: (req: PagedRequest) => Promise<PagedResponse<any>> loadPage: (req: PagedRequest) => Promise<PagedResponse<any>>;
} }
export function FileList(props: FileListProps) { export function FileList(props: FileListProps) {
const loadPage = props.loadPage; const loadPage = props.loadPage;
const actions = props.actions; const actions = props.actions;
const dispatch = useDispatch(); const dispatch = useDispatch();
const [files, setFiles] = useState<PagedResponse<VoidFileResponse>>(); const [files, setFiles] = useState<PagedResponse<VoidFileResponse>>();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const pageSize = 20; const pageSize = 20;
const [accessDenied, setAccessDenied] = useState<boolean>(); const [accessDenied, setAccessDenied] = useState<boolean>();
async function loadFileList() { async function loadFileList() {
try { try {
const pageReq = { const pageReq = {
page: page, page: page,
pageSize, pageSize,
sortBy: PagedSortBy.Date, sortBy: PagedSortBy.Date,
sortOrder: PageSortOrder.Dsc sortOrder: PageSortOrder.Dsc,
}; };
const rsp = await loadPage(pageReq); const rsp = await loadPage(pageReq);
setFiles(rsp); setFiles(rsp);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
if (e instanceof ApiError) { if (e instanceof ApiError) {
if (e.statusCode === 401) { if (e.statusCode === 401) {
dispatch(logout()); dispatch(logout());
} else if (e.statusCode === 403) { } else if (e.statusCode === 403) {
setAccessDenied(true); setAccessDenied(true);
}
}
} }
}
} }
}
function renderItem(i: VoidFileResponse) { function renderItem(i: VoidFileResponse) {
const meta = i.metadata; const meta = i.metadata;
const bw = i.bandwidth; const bw = i.bandwidth;
return (
<tr key={i.id}>
<td><Link to={`/${i.id}`}>{i.id.substring(0, 4)}..</Link></td>
<td>{meta?.name ? (meta?.name.length > 20 ? `${meta?.name.substring(0, 20)}..` : meta?.name) : null}</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>
{actions ? actions(i) : null}
</tr>
);
}
useEffect(() => {
loadFileList().catch(console.error)
}, [page]);
if (accessDenied) {
return <h3>Access Denied</h3>;
}
return ( return (
<table> <tr key={i.id}>
<thead> <td>
<tr> <Link to={`/${i.id}`}>{i.id.substring(0, 4)}..</Link>
<th>Id</th> </td>
<th>Name</th> <td>
<th>Uploaded</th> {meta?.name
<th>Size</th> ? meta?.name.length > 20
<th>Egress</th> ? `${meta?.name.substring(0, 20)}..`
{actions ? <th>Actions</th> : null} : meta?.name
</tr> : null}
</thead> </td>
<tbody> <td>{meta?.uploaded ? moment(meta?.uploaded).fromNow() : null}</td>
{files ? files.results.map(a => renderItem(a)) : <tr> <td>{meta?.size ? FormatBytes(meta?.size, 2) : null}</td>
<td colSpan={99}>No files</td> <td>{bw ? FormatBytes(bw.egress, 2) : null}</td>
</tr>} {actions ? actions(i) : null}
</tbody> </tr>
<tbody>
<tr>
<td colSpan={999}>
{files &&
<PageSelector onSelectPage={(x) => setPage(x)} page={page} total={files.totalResults}
pageSize={pageSize}/>}
</td>
</tr>
</tbody>
</table>
); );
}
useEffect(() => {
loadFileList().catch(console.error);
}, [page]);
if (accessDenied) {
return <h3>Access Denied</h3>;
}
return (
<table>
<thead>
<tr>
<th>Id</th>
<th>Name</th>
<th>Uploaded</th>
<th>Size</th>
<th>Egress</th>
{actions ? <th>Actions</th> : null}
</tr>
</thead>
<tbody>
{files ? (
files.results.map((a) => renderItem(a))
) : (
<tr>
<td colSpan={99}>No files</td>
</tr>
)}
</tbody>
<tbody>
<tr>
<td colSpan={999}>
{files && (
<PageSelector
onSelectPage={(x) => setPage(x)}
page={page}
total={files.totalResults}
pageSize={pageSize}
/>
)}
</td>
</tr>
</tbody>
</table>
);
} }

View File

@ -1,26 +1,27 @@
import {useState} from "react"; import { useState } from "react";
import {RateCalculator} from "./RateCalculator"; import { RateCalculator } from "./RateCalculator";
export function useFileTransfer() { export function useFileTransfer() {
const [speed, setSpeed] = useState(0); const [speed, setSpeed] = useState(0);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const calc = new RateCalculator(); const calc = new RateCalculator();
return { return {
speed, progress, speed,
setFileSize: (size: number) => { progress,
calc.SetFileSize(size); setFileSize: (size: number) => {
}, calc.SetFileSize(size);
update: (bytes: number) => { },
calc.ReportProgress(bytes); update: (bytes: number) => {
setSpeed(calc.GetSpeed()); calc.ReportProgress(bytes);
setProgress(calc.GetProgress()); setSpeed(calc.GetSpeed());
}, setProgress(calc.GetProgress());
loaded: (loaded: number) => { },
calc.ReportLoaded(loaded); loaded: (loaded: number) => {
setSpeed(calc.GetSpeed()); calc.ReportLoaded(loaded);
setProgress(calc.GetProgress()); setSpeed(calc.GetSpeed());
}, setProgress(calc.GetProgress());
reset: () => calc.Reset() },
} reset: () => calc.Reset(),
};
} }

View File

@ -1,16 +1,16 @@
.header { .header {
user-select: none; user-select: none;
display: flex; display: flex;
padding: 5px 0; padding: 5px 0;
align-items: center; align-items: center;
} }
.header .title { .header .title {
font-size: 30px; font-size: 30px;
line-height: 2; line-height: 2;
flex-grow: 1; flex-grow: 1;
} }
.header img.logo { .header img.logo {
height: 80px; height: 80px;
} }

View File

@ -1,65 +1,72 @@
import "./Header.css"; import "./Header.css";
import VoidCat from "../../image/voidcat.png"; import VoidCat from "../../image/voidcat.png";
import {useEffect} from "react"; import { useEffect } from "react";
import {Link} from "react-router-dom"; import { Link } from "react-router-dom";
import {useDispatch, useSelector} from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import {InlineProfile} from "./InlineProfile"; import { InlineProfile } from "./InlineProfile";
import {logout, setAuth, setProfile} from "../../LoginState"; import { logout, setAuth, setProfile } from "../../LoginState";
import {setInfo} from "../../SiteInfoStore"; import { setInfo } from "../../SiteInfoStore";
import useApi from "Hooks/UseApi"; import useApi from "Hooks/UseApi";
import {RootState} from "Store"; import { RootState } from "Store";
export function Header() { export function Header() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const jwt = useSelector((s: RootState) => s.login.jwt); const jwt = useSelector((s: RootState) => s.login.jwt);
const profile = useSelector((s: RootState) => s.login.profile) const profile = useSelector((s: RootState) => s.login.profile);
const Api = useApi(); const Api = useApi();
async function initProfile() { async function initProfile() {
if (jwt && !profile) { if (jwt && !profile) {
try { try {
const me = await Api.getUser("me"); const me = await Api.getUser("me");
dispatch(setProfile(me)); dispatch(setProfile(me));
} catch (e) { } catch (e) {
console.error(e); console.error(e);
dispatch(logout()); dispatch(logout());
} }
} else if (window.location.pathname === "/login" && window.location.hash.length > 1) { } else if (
dispatch(setAuth({ window.location.pathname === "/login" &&
jwt: window.location.hash.substring(1) window.location.hash.length > 1
})); ) {
} dispatch(
setAuth({
jwt: window.location.hash.substring(1),
}),
);
} }
}
async function loadStats() { async function loadStats() {
const info = await Api.info(); const info = await Api.info();
dispatch(setInfo(info)); dispatch(setInfo(info));
} }
useEffect(() => { useEffect(() => {
initProfile().catch(console.error); initProfile().catch(console.error);
loadStats().catch(console.error); loadStats().catch(console.error);
}, [jwt]); }, [jwt]);
return ( return (
<div className="header page"> <div className="header page">
<img src={VoidCat} alt="logo" className="logo" />
<img src={VoidCat} alt="logo" className="logo"/> <div className="title">
<div className="title"> <Link to="/">{window.location.hostname}</Link>
<Link to="/"> </div>
{window.location.hostname} {profile ? (
</Link> <InlineProfile
</div> profile={profile}
{profile ? options={{
<InlineProfile profile={profile} options={{ showName: false,
showName: false }}
}}/> : />
<Link to="/login"> ) : (
<div className="btn">Login</div> <Link to="/login">
</Link>} <div className="btn">Login</div>
</div> </Link>
) )}
</div>
);
} }

View File

@ -1,21 +1,26 @@
import { MouseEventHandler } from "react"; import { MouseEventHandler } from "react";
type Props = { type Props = {
name: string; name: string;
size?: number; size?: number;
className?: string; className?: string;
onClick?: MouseEventHandler<SVGSVGElement>; onClick?: MouseEventHandler<SVGSVGElement>;
}; };
const Icon = (props: Props) => { const Icon = (props: Props) => {
const size = props.size || 20; const size = props.size || 20;
const href = "/icons.svg#" + props.name; const href = "/icons.svg#" + props.name;
return ( return (
<svg width={size} height={size} className={props.className} onClick={props.onClick}> <svg
<use href={href} /> width={size}
</svg> height={size}
); className={props.className}
onClick={props.onClick}
>
<use href={href} />
</svg>
);
}; };
export default Icon; export default Icon;

View File

@ -1,20 +1,22 @@
.image-grid { .image-grid {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
} }
.image-grid > a { .image-grid > a {
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
width: 150px; width: 150px;
height: 150px; height: 150px;
} }
.image-grid img, .image-grid video, .image-grid audio { .image-grid img,
max-width: stretch; .image-grid video,
max-height: stretch; .image-grid audio {
max-width: stretch;
max-height: stretch;
} }

View File

@ -1,105 +1,120 @@
import "./ImageGrid.css"; import "./ImageGrid.css";
import {ApiError, PagedRequest, PagedResponse, PagedSortBy, PageSortOrder, VoidFileResponse} from "@void-cat/api"; import {
import {useEffect, useState} from "react"; ApiError,
import {Link} from "react-router-dom"; PagedRequest,
import {useDispatch} from "react-redux"; PagedResponse,
PagedSortBy,
PageSortOrder,
VoidFileResponse,
} from "@void-cat/api";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useDispatch } from "react-redux";
import {logout} from "../../LoginState"; import { logout } from "../../LoginState";
import {PageSelector} from "./PageSelector"; import { PageSelector } from "./PageSelector";
interface ImageGridProps { interface ImageGridProps {
loadPage: (req: PagedRequest) => Promise<PagedResponse<any>> loadPage: (req: PagedRequest) => Promise<PagedResponse<any>>;
} }
export default function ImageGrid(props: ImageGridProps) { export default function ImageGrid(props: ImageGridProps) {
const loadPage = props.loadPage; const loadPage = props.loadPage;
const dispatch = useDispatch(); const dispatch = useDispatch();
const [files, setFiles] = useState<PagedResponse<VoidFileResponse>>(); const [files, setFiles] = useState<PagedResponse<VoidFileResponse>>();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const pageSize = 100; const pageSize = 100;
const [accessDenied, setAccessDenied] = useState<boolean>(); const [accessDenied, setAccessDenied] = useState<boolean>();
async function loadFileList() { async function loadFileList() {
try { try {
const pageReq = { const pageReq = {
page: page, page: page,
pageSize, pageSize,
sortBy: PagedSortBy.Date, sortBy: PagedSortBy.Date,
sortOrder: PageSortOrder.Dsc sortOrder: PageSortOrder.Dsc,
}; };
const rsp = await loadPage(pageReq); const rsp = await loadPage(pageReq);
setFiles(rsp); setFiles(rsp);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
if (e instanceof ApiError) { if (e instanceof ApiError) {
if (e.statusCode === 401) { if (e.statusCode === 401) {
dispatch(logout()); dispatch(logout());
} else if (e.statusCode === 403) { } else if (e.statusCode === 403) {
setAccessDenied(true); setAccessDenied(true);
}
}
} }
}
} }
}
useEffect(() => { useEffect(() => {
loadFileList().catch(console.error) loadFileList().catch(console.error);
}, [page]); }, [page]);
function renderPreview(info: VoidFileResponse) { function renderPreview(info: VoidFileResponse) {
const link = `/d/${info.id}`; const link = `/d/${info.id}`;
if (info.metadata) { if (info.metadata) {
switch (info.metadata.mimeType) { switch (info.metadata.mimeType) {
case "image/avif": case "image/avif":
case "image/bmp": case "image/bmp":
case "image/gif": case "image/gif":
case "image/svg+xml": case "image/svg+xml":
case "image/tiff": case "image/tiff":
case "image/webp": case "image/webp":
case "image/jpg": case "image/jpg":
case "image/jpeg": case "image/jpeg":
case "image/png": { case "image/png": {
return <img src={link} alt={info.metadata.name}/>; return <img src={link} alt={info.metadata.name} />;
}
case "audio/aac":
case "audio/opus":
case "audio/wav":
case "audio/webm":
case "audio/midi":
case "audio/mpeg":
case "audio/ogg": {
return <audio src={link}/>;
}
case "video/x-msvideo":
case "video/mpeg":
case "video/ogg":
case "video/mp2t":
case "video/mp4":
case "video/matroksa":
case "video/x-matroska":
case "video/webm":
case "video/quicktime": {
return <video src={link}/>;
}
default: {
return <b>{info.metadata?.name ?? info.id}</b>
}
}
} }
case "audio/aac":
case "audio/opus":
case "audio/wav":
case "audio/webm":
case "audio/midi":
case "audio/mpeg":
case "audio/ogg": {
return <audio src={link} />;
}
case "video/x-msvideo":
case "video/mpeg":
case "video/ogg":
case "video/mp2t":
case "video/mp4":
case "video/matroksa":
case "video/x-matroska":
case "video/webm":
case "video/quicktime": {
return <video src={link} />;
}
default: {
return <b>{info.metadata?.name ?? info.id}</b>;
}
}
} }
}
if (accessDenied) { if (accessDenied) {
return <h3>Access Denied</h3> return <h3>Access Denied</h3>;
} }
return <> return (
<div className="image-grid"> <>
{files?.results.map(v => <Link key={v.id} to={`/${v.id}`}> <div className="image-grid">
{renderPreview(v)} {files?.results.map((v) => (
</Link>)} <Link key={v.id} to={`/${v.id}`}>
</div> {renderPreview(v)}
<PageSelector onSelectPage={(x) => setPage(x)} page={page} total={files?.totalResults ?? 0} </Link>
pageSize={pageSize}/> ))}
</div>
<PageSelector
onSelectPage={(x) => setPage(x)}
page={page}
total={files?.totalResults ?? 0}
pageSize={pageSize}
/>
</> </>
);
} }

View File

@ -1,19 +1,18 @@
.small-profile { .small-profile {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
.small-profile .avatar { .small-profile .avatar {
width: 64px; width: 64px;
height: 64px; height: 64px;
border-radius: 16px; border-radius: 16px;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
background-repeat: no-repeat; background-repeat: no-repeat;
border: 2px solid; border: 2px solid;
} }
.small-profile .name { .small-profile .name {
padding-left: 15px; padding-left: 15px;
} }

View File

@ -1,49 +1,49 @@
import "./InlineProfile.css"; import "./InlineProfile.css";
import {CSSProperties} from "react"; import { CSSProperties } from "react";
import {Link} from "react-router-dom"; import { Link } from "react-router-dom";
import {Profile} from "@void-cat/api"; import { Profile } from "@void-cat/api";
import {DefaultAvatar} from "Const"; import { DefaultAvatar } from "Const";
const DefaultSize = 64; const DefaultSize = 64;
interface InlineProfileProps { interface InlineProfileProps {
profile: Profile profile: Profile;
options?: { options?: {
size?: number size?: number;
showName?: boolean showName?: boolean;
link?: boolean link?: boolean;
} };
} }
export function InlineProfile({profile, options}: InlineProfileProps) { export function InlineProfile({ profile, options }: InlineProfileProps) {
options = { options = {
size: DefaultSize, size: DefaultSize,
showName: true, showName: true,
link: true, link: true,
...options ...options,
}; };
let avatarUrl = profile.avatar ?? DefaultAvatar; let avatarUrl = profile.avatar ?? DefaultAvatar;
if (!avatarUrl.startsWith("http")) { if (!avatarUrl.startsWith("http")) {
avatarUrl = `/d/${avatarUrl}`; avatarUrl = `/d/${avatarUrl}`;
} }
let avatarStyles = { let avatarStyles = {
backgroundImage: `url(${avatarUrl})` backgroundImage: `url(${avatarUrl})`,
} as CSSProperties; } as CSSProperties;
if (options.size !== DefaultSize) { if (options.size !== DefaultSize) {
avatarStyles.width = `${options.size}px`; avatarStyles.width = `${options.size}px`;
avatarStyles.height = `${options.size}px`; avatarStyles.height = `${options.size}px`;
} }
const elms = ( const elms = (
<div className="small-profile"> <div className="small-profile">
<div className="avatar" style={avatarStyles}/> <div className="avatar" style={avatarStyles} />
{options.showName ? <div className="name">{profile.name}</div> : null} {options.showName ? <div className="name">{profile.name}</div> : null}
</div> </div>
); );
if (options.link === true) { if (options.link === true) {
return <Link to={`/u/${profile.id}`}>{elms}</Link> return <Link to={`/u/${profile.id}`}>{elms}</Link>;
} }
return elms; return elms;
} }

View File

@ -1,8 +1,8 @@
.login .error-msg { .login .error-msg {
color: red; color: red;
padding: 10px; padding: 10px;
border: 1px solid red; border: 1px solid red;
border-radius: 10px; border-radius: 10px;
margin-top: 10px; margin-top: 10px;
width: fit-content; width: fit-content;
} }

View File

@ -1,64 +1,92 @@
import {useState} from "react"; import { useState } from "react";
import {useDispatch, useSelector} from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import HCaptcha from "@hcaptcha/react-hcaptcha"; import HCaptcha from "@hcaptcha/react-hcaptcha";
import "./Login.css"; import "./Login.css";
import {setAuth} from "../../LoginState"; import { setAuth } from "../../LoginState";
import {VoidButton} from "./VoidButton"; import { VoidButton } from "./VoidButton";
import useApi from "Hooks/UseApi"; import useApi from "Hooks/UseApi";
import {RootState} from "Store"; import { RootState } from "Store";
export function Login() { export function Login() {
const Api = useApi(); const Api = useApi();
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [captchaResponse, setCaptchaResponse] = useState(""); const [captchaResponse, setCaptchaResponse] = useState("");
const captchaKey = useSelector((s: RootState) => s.info.info?.captchaSiteKey); const captchaKey = useSelector((s: RootState) => s.info.info?.captchaSiteKey);
const oAuthProviders = useSelector((s: RootState) => s.info.info?.oAuthProviders); const oAuthProviders = useSelector(
const dispatch = useDispatch(); (s: RootState) => s.info.info?.oAuthProviders,
);
const dispatch = useDispatch();
async function login(fnLogin: typeof Api.login) { async function login(fnLogin: typeof Api.login) {
setError(""); setError("");
try { try {
const rsp = await fnLogin(username, password, captchaResponse); const rsp = await fnLogin(username, password, captchaResponse);
if (rsp.jwt) { if (rsp.jwt) {
dispatch(setAuth({ dispatch(
jwt: rsp.jwt, setAuth({
profile: rsp.profile! jwt: rsp.jwt,
})); profile: rsp.profile!,
} else { }),
setError(rsp.error!); );
} } else {
} catch (e) { setError(rsp.error!);
if (e instanceof Error) { }
setError(e.message); } catch (e) {
} if (e instanceof Error) {
} setError(e.message);
}
} }
}
return ( return (
<div className="login"> <div className="login">
<h2>Login</h2> <h2>Login</h2>
<dl> <dl>
<dt>Username:</dt> <dt>Username:</dt>
<dd><input type="text" placeholder="user@example.com" onChange={(e) => setUsername(e.target.value)} <dd>
value={username}/> <input
</dd> type="text"
<dt>Password:</dt> placeholder="user@example.com"
<dd><input type="password" onChange={(e) => setPassword(e.target.value)} value={password}/></dd> onChange={(e) => setUsername(e.target.value)}
</dl> value={username}
{captchaKey ? <HCaptcha sitekey={captchaKey} onVerify={v => setCaptchaResponse(v)}/> : null} />
<VoidButton onClick={() => login(Api.login.bind(Api))}>Login</VoidButton> </dd>
<VoidButton onClick={() => login(Api.register.bind(Api))}>Register</VoidButton> <dt>Password:</dt>
<br/> <dd>
{oAuthProviders ? <input
oAuthProviders.map(a => <VoidButton key={a} onClick={() => window.location.href = `/auth/${a}`}> type="password"
Login with {a} onChange={(e) => setPassword(e.target.value)}
</VoidButton>) : null} value={password}
{error && <div className="error-msg">{error}</div>} />
</div> </dd>
); </dl>
{captchaKey ? (
<HCaptcha
sitekey={captchaKey}
onVerify={(v) => setCaptchaResponse(v)}
/>
) : null}
<VoidButton onClick={() => login(Api.login.bind(Api))}>Login</VoidButton>
<VoidButton onClick={() => login(Api.register.bind(Api))}>
Register
</VoidButton>
<br />
{oAuthProviders
? oAuthProviders.map((a) => (
<VoidButton
key={a}
onClick={() => (window.location.href = `/auth/${a}`)}
>
Login with {a}
</VoidButton>
))
: null}
{error && <div className="error-msg">{error}</div>}
</div>
);
} }

View File

@ -1,35 +1,34 @@
.page-buttons { .page-buttons {
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: column;
width: min-content; width: min-content;
margin-top: 10px; margin-top: 10px;
white-space: nowrap; white-space: nowrap;
} }
.page-buttons > div { .page-buttons > div {
padding: 5px 8px; padding: 5px 8px;
border: 1px solid; border: 1px solid;
user-select: none; user-select: none;
cursor: pointer; cursor: pointer;
} }
.page-buttons > div.active { .page-buttons > div.active {
background-color: #333; background-color: #333;
font-weight: bold; font-weight: bold;
} }
.page-buttons > div:first-child { .page-buttons > div:first-child {
border-top-left-radius: 3px; border-top-left-radius: 3px;
border-bottom-left-radius: 3px; border-bottom-left-radius: 3px;
} }
.page-buttons > div:last-child { .page-buttons > div:last-child {
border-top-right-radius: 3px; border-top-right-radius: 3px;
border-bottom-right-radius: 3px; border-bottom-right-radius: 3px;
} }
.page-buttons > small { .page-buttons > small {
line-height: 32px; line-height: 32px;
margin-left: 10px; margin-left: 10px;
} }

View File

@ -1,43 +1,52 @@
import "./PageSelector.css"; import "./PageSelector.css";
interface PageSelectorProps { interface PageSelectorProps {
total: number total: number;
pageSize: number pageSize: number;
page: number page: number;
onSelectPage?: (v: number) => void onSelectPage?: (v: number) => void;
options?: { options?: {
showPages: number showPages: number;
} };
} }
export function PageSelector(props: PageSelectorProps) { export function PageSelector(props: PageSelectorProps) {
const total = props.total; const total = props.total;
const pageSize = props.pageSize; const pageSize = props.pageSize;
const page = props.page; const page = props.page;
const onSelectPage = props.onSelectPage; const onSelectPage = props.onSelectPage;
const options = { const options = {
showPages: 3, showPages: 3,
...props.options ...props.options,
}; };
const totalPages = Math.floor(total / pageSize); const totalPages = Math.floor(total / pageSize);
const first = Math.max(0, page - options.showPages); const first = Math.max(0, page - options.showPages);
const firstDiff = page - first; const firstDiff = page - first;
const last = Math.min(totalPages, page + options.showPages + options.showPages - firstDiff); const last = Math.min(
totalPages,
page + options.showPages + options.showPages - firstDiff,
);
const buttons = []; const buttons = [];
for (let x = first; x <= last; x++) { for (let x = first; x <= last; x++) {
buttons.push(<div onClick={() => onSelectPage?.(x)} key={x} className={page === x ? "active" : ""}> buttons.push(
{x + 1} <div
</div>); onClick={() => onSelectPage?.(x)}
} key={x}
className={page === x ? "active" : ""}
return ( >
<div className="page-buttons"> {x + 1}
<div onClick={() => onSelectPage?.(0)}>&lt;&lt;</div> </div>,
{buttons}
<div onClick={() => onSelectPage?.(totalPages)}>&gt;&gt;</div>
<small>Total: {total}</small>
</div>
); );
}
return (
<div className="page-buttons">
<div onClick={() => onSelectPage?.(0)}>&lt;&lt;</div>
{buttons}
<div onClick={() => onSelectPage?.(totalPages)}>&gt;&gt;</div>
<small>Total: {total}</small>
</div>
);
} }

View File

@ -1,68 +1,68 @@
interface RateReport { interface RateReport {
time: number time: number;
amount: number amount: number;
} }
export class RateCalculator { export class RateCalculator {
#reports: Array<RateReport> = []; #reports: Array<RateReport> = [];
#lastLoaded = 0; #lastLoaded = 0;
#progress = 0; #progress = 0;
#speed = 0; #speed = 0;
#fileSize = 0; #fileSize = 0;
constructor() { constructor() {
this.Reset(); this.Reset();
}
SetFileSize(size: number) {
this.#fileSize = size;
}
GetProgress() {
return this.#progress;
}
GetSpeed() {
return this.#speed;
}
Reset() {
this.#reports = [];
this.#lastLoaded = 0;
this.#progress = 0;
this.#speed = 0;
}
ReportProgress(amount: number) {
this.#reports.push({
time: new Date().getTime(),
amount,
});
this.#lastLoaded += amount;
this.#progress = this.#lastLoaded / this.#fileSize;
this.#speed = this.RateWindow(5);
}
ReportLoaded(loaded: number) {
this.#reports.push({
time: new Date().getTime(),
amount: loaded - this.#lastLoaded,
});
this.#lastLoaded = loaded;
this.#progress = this.#lastLoaded / this.#fileSize;
this.#speed = this.RateWindow(5);
}
RateWindow(s: number) {
let total = 0.0;
const windowStart = new Date().getTime() - s * 1000;
for (let r of this.#reports) {
if (r.time >= windowStart) {
total += r.amount;
}
} }
SetFileSize(size: number) { return total / s;
this.#fileSize = size; }
}
GetProgress() {
return this.#progress;
}
GetSpeed() {
return this.#speed;
}
Reset() {
this.#reports = [];
this.#lastLoaded = 0;
this.#progress = 0;
this.#speed = 0;
}
ReportProgress(amount: number) {
this.#reports.push({
time: new Date().getTime(),
amount
});
this.#lastLoaded += amount;
this.#progress = this.#lastLoaded / this.#fileSize;
this.#speed = this.RateWindow(5);
}
ReportLoaded(loaded: number) {
this.#reports.push({
time: new Date().getTime(),
amount: loaded - this.#lastLoaded
});
this.#lastLoaded = loaded;
this.#progress = this.#lastLoaded / this.#fileSize;
this.#speed = this.RateWindow(5);
}
RateWindow(s: number) {
let total = 0.0;
const windowStart = new Date().getTime() - (s * 1000);
for (let r of this.#reports) {
if (r.time >= windowStart) {
total += r.amount;
}
}
return total / s;
}
} }

View File

@ -1,56 +1,63 @@
import React, {MouseEvent, ReactNode, useEffect, useState} from "react"; import React, { MouseEvent, ReactNode, useEffect, useState } from "react";
import Icon from "./Icon"; import Icon from "./Icon";
interface VoidButtonProps { interface VoidButtonProps {
onClick?: (e: MouseEvent<HTMLDivElement>) => Promise<unknown> | unknown onClick?: (e: MouseEvent<HTMLDivElement>) => Promise<unknown> | unknown;
options?: { options?: {
showSuccess: boolean showSuccess: boolean;
} };
children: ReactNode children: ReactNode;
} }
export function VoidButton(props: VoidButtonProps) { export function VoidButton(props: VoidButtonProps) {
const options = { const options = {
showSuccess: false, showSuccess: false,
...props.options ...props.options,
}; };
const [disabled, setDisabled] = useState(false); const [disabled, setDisabled] = useState(false);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
async function handleClick(e: MouseEvent<HTMLDivElement>) { async function handleClick(e: MouseEvent<HTMLDivElement>) {
if (disabled) return; if (disabled) return;
setDisabled(true); setDisabled(true);
let fn = props.onClick; let fn = props.onClick;
try { try {
if (typeof fn === "function") { if (typeof fn === "function") {
const ret = fn(e); const ret = fn(e);
if (ret && typeof ret === "object" && "then" in ret) { if (ret && typeof ret === "object" && "then" in ret) {
await (ret as Promise<unknown>); await (ret as Promise<unknown>);
}
setSuccess(options.showSuccess);
}
} catch (e) {
console.error(e);
} }
setSuccess(options.showSuccess);
setDisabled(false); }
} catch (e) {
console.error(e);
} }
useEffect(() => { setDisabled(false);
if (success) { }
setTimeout(() => setSuccess(false), 1000);
}
}, [success]);
return ( useEffect(() => {
<div className="flex-inline flex-center"> if (success) {
<div> setTimeout(() => setSuccess(false), 1000);
<div className={`btn${disabled ? " disabled" : ""}`} onClick={handleClick}> }
{props.children} }, [success]);
</div>
</div> return (
{success && <div><Icon name="check-circle"/></div>} <div className="flex-inline flex-center">
<div>
<div
className={`btn${disabled ? " disabled" : ""}`}
onClick={handleClick}
>
{props.children}
</div> </div>
); </div>
{success && (
<div>
<Icon name="check-circle" />
</div>
)}
</div>
);
} }

View File

@ -1,35 +1,35 @@
.modal-bg { .modal-bg {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background-color: rgba(0, 0, 0, 0.6); background-color: rgba(0, 0, 0, 0.6);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.modal-bg .modal { .modal-bg .modal {
min-height: 100px; min-height: 100px;
min-width: 300px; min-width: 300px;
background-color: #bbb; background-color: #bbb;
color: #000; color: #000;
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
} }
.modal-bg .modal .modal-header { .modal-bg .modal .modal-header {
text-align: center; text-align: center;
border-bottom: 1px solid; border-bottom: 1px solid;
margin: 0; margin: 0;
line-height: 2em; line-height: 2em;
background-color: #222; background-color: #222;
color: #bbb; color: #bbb;
font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
} }
.modal-bg .modal .modal-body { .modal-bg .modal .modal-body {
padding: 10px; padding: 10px;
} }

View File

@ -1,25 +1,21 @@
import "./VoidModal.css"; import "./VoidModal.css";
import {CSSProperties, ReactNode} from "react"; import { CSSProperties, ReactNode } from "react";
interface VoidModalProps { interface VoidModalProps {
title?: string title?: string;
style?: CSSProperties style?: CSSProperties;
children: ReactNode children: ReactNode;
} }
export default function VoidModal(props: VoidModalProps) { export default function VoidModal(props: VoidModalProps) {
const title = props.title; const title = props.title;
const style = props.style; const style = props.style;
return ( return (
<div className="modal-bg"> <div className="modal-bg">
<div className="modal" style={style}> <div className="modal" style={style}>
<div className="modal-header"> <div className="modal-header">{title ?? "Unknown modal"}</div>
{title ?? "Unknown modal"} <div className="modal-body">{props.children ?? "Missing body"}</div>
</div> </div>
<div className="modal-body"> </div>
{props.children ?? "Missing body"} );
</div>
</div>
</div>
)
} }

View File

@ -1,10 +1,10 @@
import {useSelector} from "react-redux"; import { useSelector } from "react-redux";
import {VoidApi} from "@void-cat/api"; import { VoidApi } from "@void-cat/api";
import {RootState} from "Store"; import { RootState } from "Store";
import {ApiHost} from "Const"; import { ApiHost } from "Const";
export default function useApi() { export default function useApi() {
const auth = useSelector((s: RootState) => s.login.jwt); const auth = useSelector((s: RootState) => s.login.jwt);
return new VoidApi(ApiHost, auth); return new VoidApi(ApiHost, auth ? () => Promise.resolve(`Bearer ${auth}`) : undefined);
} }

View File

@ -1,39 +1,39 @@
import {createSlice, PayloadAction} from "@reduxjs/toolkit"; import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import {Profile} from "@void-cat/api"; import { Profile } from "@void-cat/api";
interface LoginStore { interface LoginStore {
jwt?: string jwt?: string;
profile?: Profile profile?: Profile;
} }
interface SetAuthPayload { interface SetAuthPayload {
jwt: string jwt: string;
profile?: Profile profile?: Profile;
} }
const LocalStorageKey = "token"; const LocalStorageKey = "token";
export const LoginState = createSlice({ export const LoginState = createSlice({
name: "Login", name: "Login",
initialState: { initialState: {
jwt: window.localStorage.getItem(LocalStorageKey) ?? undefined, jwt: window.localStorage.getItem(LocalStorageKey) ?? undefined,
profile: undefined profile: undefined,
} as LoginStore, } as LoginStore,
reducers: { reducers: {
setAuth: (state, action: PayloadAction<SetAuthPayload>) => { setAuth: (state, action: PayloadAction<SetAuthPayload>) => {
state.jwt = action.payload.jwt; state.jwt = action.payload.jwt;
state.profile = action.payload.profile; state.profile = action.payload.profile;
window.localStorage.setItem(LocalStorageKey, state.jwt); window.localStorage.setItem(LocalStorageKey, state.jwt);
}, },
setProfile: (state, action: PayloadAction<Profile>) => { setProfile: (state, action: PayloadAction<Profile>) => {
state.profile = action.payload; state.profile = action.payload;
}, },
logout: (state) => { logout: (state) => {
state.jwt = undefined; state.jwt = undefined;
state.profile = undefined; state.profile = undefined;
window.localStorage.removeItem(LocalStorageKey); window.localStorage.removeItem(LocalStorageKey);
} },
} },
}); });
export const {setAuth, setProfile, logout} = LoginState.actions; export const { setAuth, setProfile, logout } = LoginState.actions;
export default LoginState.reducer; export default LoginState.reducer;

View File

@ -1,3 +1,2 @@
.donate { .donate {
} }

View File

@ -1,34 +1,50 @@
import "./Donate.css" import "./Donate.css";
import {useState} from "react"; import { useState } from "react";
export function Donate() { export function Donate() {
const Hostname = "pay.v0l.io"; const Hostname = "pay.v0l.io";
const StoreId = "GdRya8MAvZYhyviA4ypFgijBknNoDEkg12ro8efLcZp5"; const StoreId = "GdRya8MAvZYhyviA4ypFgijBknNoDEkg12ro8efLcZp5";
const [currency, setCurrency] = useState("USD"); const [currency, setCurrency] = useState("USD");
const [price, setPrice] = useState(1); const [price, setPrice] = useState(1);
return ( return (
<div className="page donate"> <div className="page donate">
<h2>Donate with Bitcoin</h2> <h2>Donate with Bitcoin</h2>
<form method="POST" action={`https://${Hostname}/api/v1/invoices`} className="flex"> <form
<input type="hidden" name="storeId" value={StoreId}/> method="POST"
<input type="hidden" name="checkoutDesc" value="Donation"/> action={`https://${Hostname}/api/v1/invoices`}
<div className="flex"> className="flex"
<input name="price" type="number" min="1" step="1" value={price} >
onChange={(e) => setPrice(parseFloat(e.target.value))}/> <input type="hidden" name="storeId" value={StoreId} />
<select name="currency" value={currency} onChange={(e) => setCurrency(e.target.value)}> <input type="hidden" name="checkoutDesc" value="Donation" />
<option>USD</option> <div className="flex">
<option>GBP</option> <input
<option>EUR</option> name="price"
<option>BTC</option> type="number"
</select> min="1"
</div> step="1"
<input type="image" value={price}
name="submit" onChange={(e) => setPrice(parseFloat(e.target.value))}
src={`https://${Hostname}/img/paybutton/pay.svg`} />
alt="Pay with BTCPay Server, a Self-Hosted Bitcoin Payment Processor"/> <select
</form> name="currency"
value={currency}
onChange={(e) => setCurrency(e.target.value)}
>
<option>USD</option>
<option>GBP</option>
<option>EUR</option>
<option>BTC</option>
</select>
</div> </div>
); <input
type="image"
name="submit"
src={`https://${Hostname}/img/paybutton/pay.svg`}
alt="Pay with BTCPay Server, a Self-Hosted Bitcoin Payment Processor"
/>
</form>
</div>
);
} }

View File

@ -1,38 +1,41 @@
.preview { .preview {
margin-top: 2vh; margin-top: 2vh;
} }
.preview img, .preview video, .preview object, .preview audio { .preview img,
max-width: 100%; .preview video,
max-height: 100vh; .preview object,
.preview audio {
max-width: 100%;
max-height: 100vh;
} }
.preview .file-stats { .preview .file-stats {
line-height: 32px; line-height: 32px;
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: column;
text-align: center; text-align: center;
} }
.preview .file-stats svg { .preview .file-stats svg {
vertical-align: middle; vertical-align: middle;
margin-right: 10px; margin-right: 10px;
} }
.preview .virus-warning { .preview .virus-warning {
padding: 10px; padding: 10px;
border-radius: 10px; border-radius: 10px;
border: 1px solid red; border: 1px solid red;
margin-bottom: 5px; margin-bottom: 5px;
} }
.preview .encrypted { .preview .encrypted {
padding: 10px; padding: 10px;
border-radius: 10px; border-radius: 10px;
border: 2px solid #bbbbbb; border: 2px solid #bbbbbb;
text-align: center; text-align: center;
} }
.error { .error {
color: red; color: red;
} }

View File

@ -1,285 +1,329 @@
import "./FilePreview.css"; import "./FilePreview.css";
import {Fragment, useEffect, useState} from "react"; import { Fragment, useEffect, useState } from "react";
import {useParams} from "react-router-dom"; import { useParams } from "react-router-dom";
import {Helmet} from "react-helmet"; import { Helmet } from "react-helmet";
import {PaymentOrder, VoidFileResponse, StreamEncryption} from "@void-cat/api"; import {
PaymentOrder,
VoidFileResponse,
StreamEncryption,
} from "@void-cat/api";
import {TextPreview} from "../Components/FilePreview/TextPreview"; import { TextPreview } from "../Components/FilePreview/TextPreview";
import {FileEdit} from "../Components/FileEdit/FileEdit"; import { FileEdit } from "../Components/FileEdit/FileEdit";
import {FilePayment} from "../Components/FilePreview/FilePayment"; import { FilePayment } from "../Components/FilePreview/FilePayment";
import {InlineProfile} from "../Components/Shared/InlineProfile"; import { InlineProfile } from "../Components/Shared/InlineProfile";
import {VoidButton} from "../Components/Shared/VoidButton"; import { VoidButton } from "../Components/Shared/VoidButton";
import {useFileTransfer} from "../Components/Shared/FileTransferHook"; import { useFileTransfer } from "../Components/Shared/FileTransferHook";
import Icon from "../Components/Shared/Icon"; import Icon from "../Components/Shared/Icon";
import useApi from "Hooks/UseApi"; import useApi from "Hooks/UseApi";
import {FormatBytes} from "Util"; import { FormatBytes } from "Util";
import {ApiHost} from "Const"; import { ApiHost } from "Const";
export function FilePreview() { export function FilePreview() {
const Api = useApi(); const Api = useApi();
const params = useParams(); const params = useParams();
const [info, setInfo] = useState<VoidFileResponse>(); const [info, setInfo] = useState<VoidFileResponse>();
const [order, setOrder] = useState<PaymentOrder>(); const [order, setOrder] = useState<PaymentOrder>();
const [link, setLink] = useState("#"); const [link, setLink] = useState("#");
const [key, setKey] = useState(""); const [key, setKey] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const {speed, progress, update, setFileSize} = useFileTransfer(); const { speed, progress, update, setFileSize } = useFileTransfer();
async function loadInfo() { async function loadInfo() {
if (params.id) { if (params.id) {
const i = await Api.fileInfo(params.id); const i = await Api.fileInfo(params.id);
setInfo(i); setInfo(i);
}
} }
}
function isFileEncrypted() { function isFileEncrypted() {
return "string" === typeof info?.metadata?.encryptionParams return "string" === typeof info?.metadata?.encryptionParams;
}
function isDecrypted() {
return link.startsWith("blob:");
}
function isPaymentRequired() {
return info?.payment?.required === true && !order;
}
function canAccessFile() {
if (isPaymentRequired()) {
return false;
} }
if (isFileEncrypted() && !isDecrypted()) {
function isDecrypted() { return false;
return link.startsWith("blob:");
} }
return true;
}
function isPaymentRequired() { async function decryptFile() {
return info?.payment?.required === true && !order; if (!info) return;
}
function canAccessFile() { try {
if (isPaymentRequired()) { let hashKey = key.match(/([0-9a-z]{32}):([0-9a-z]{24})/);
return false; if (hashKey?.length === 3) {
} let [key, iv] = [hashKey[1], hashKey[2]];
if (isFileEncrypted() && !isDecrypted()) { let enc = new StreamEncryption(
return false; key,
} iv,
return true; info.metadata?.encryptionParams,
}
async function decryptFile() {
if (!info) return;
try {
let hashKey = key.match(/([0-9a-z]{32}):([0-9a-z]{24})/);
if (hashKey?.length === 3) {
let [key, iv] = [hashKey[1], hashKey[2]];
let enc = new StreamEncryption(key, iv, info.metadata?.encryptionParams);
let rsp = await fetch(link);
if (rsp.ok) {
const reader = rsp.body?.pipeThrough(enc.getDecryptionTransform())
.pipeThrough(decryptionProgressTransform());
const newResponse = new Response(reader);
setLink(window.URL.createObjectURL(await newResponse.blob()));
}
} else {
setError("Invalid encryption key format");
}
} catch (e) {
if (e instanceof Error) {
setError(e.message);
} else {
setError("Unknown error")
}
}
}
function decryptionProgressTransform() {
return new TransformStream({
transform: (chunk, controller) => {
update(chunk.length);
controller.enqueue(chunk);
}
});
}
function renderEncryptedDownload() {
if (!isFileEncrypted() || isDecrypted() || isPaymentRequired()) return;
return (
<div className="encrypted">
<h3>This file is encrypted, please enter the encryption key:</h3>
<input type="password" placeholder="Encryption key" value={key}
onChange={(e) => setKey(e.target.value)}/>
<VoidButton onClick={() => decryptFile()}>Decrypt</VoidButton>
{progress > 0 && `${(100 * progress).toFixed(0)}% (${FormatBytes(speed)}/s)`}
{error && <h4 className="error">{error}</h4>}
</div>
); );
}
function renderPayment() { let rsp = await fetch(link);
if (!info) return; if (rsp.ok) {
const reader = rsp.body
if (info.payment && info.payment.service !== 0 && !order) { ?.pipeThrough(enc.getDecryptionTransform())
return <FilePayment file={info} onPaid={loadInfo}/>; .pipeThrough(decryptionProgressTransform());
const newResponse = new Response(reader);
setLink(window.URL.createObjectURL(await newResponse.blob()));
} }
} else {
setError("Invalid encryption key format");
}
} catch (e) {
if (e instanceof Error) {
setError(e.message);
} else {
setError("Unknown error");
}
} }
}
function renderPreview() { function decryptionProgressTransform() {
if (!canAccessFile() || !info) return; return new TransformStream({
transform: (chunk, controller) => {
if (info.metadata) { update(chunk.length);
switch (info.metadata.mimeType) { controller.enqueue(chunk);
case "image/avif": },
case "image/bmp": });
case "image/gif": }
case "image/svg+xml":
case "image/tiff":
case "image/webp":
case "image/jpg":
case "image/jpeg":
case "image/png": {
return <img src={link} alt={info.metadata.name}/>;
}
case "audio/aac":
case "audio/opus":
case "audio/wav":
case "audio/webm":
case "audio/midi":
case "audio/mpeg":
case "audio/ogg": {
return <audio src={link} controls/>;
}
case "video/x-msvideo":
case "video/mpeg":
case "video/ogg":
case "video/mp2t":
case "video/mp4":
case "video/matroksa":
case "video/x-matroska":
case "video/webm":
case "video/quicktime": {
return <video src={link} controls/>;
}
case "application/json":
case "text/javascript":
case "text/html":
case "text/csv":
case "text/css":
case "text/plain": {
return <TextPreview link={link}/>;
}
case "application/pdf": {
return <object data={link}/>;
}
default: {
return <h3>{info.metadata?.name ?? info.id}</h3>
}
}
}
}
function renderOpenGraphTags() {
const tags = [
<meta key="og-site_name" property={"og:site_name"} content={"void.cat"}/>,
<meta key="og-title" property={"og:title"} content={info?.metadata?.name}/>,
<meta key="og-description" property={"og:description"} content={info?.metadata?.description}/>,
<meta key="og-url" property={"og:url"} content={`https://${window.location.host}/${info?.id}`}/>
];
const mime = info?.metadata?.mimeType;
if (mime?.startsWith("image/")) {
tags.push(<meta key="og-image" property={"og:image"} content={link}/>);
tags.push(<meta key="og-image-type" property={"og:image:type"} content={mime}/>);
} else if (mime?.startsWith("video/")) {
tags.push(<meta key="og-video" property={"og:video"} content={link}/>);
tags.push(<meta key="og-video-type" property={"og:video:type"} content={mime}/>);
} else if (mime?.startsWith("audio/")) {
tags.push(<meta key="og-audio" property={"og:audio"} content={link}/>);
tags.push(<meta key="og-audio-type" property={"og:audio:type"} content={mime}/>);
}
return tags;
}
function renderVirusWarning() {
if (info?.virusScan?.isVirus === true) {
let scanResult = info.virusScan;
return (
<div className="virus-warning">
<p>
This file apears to be a virus, take care when downloading this file.
</p>
Detected as:
<pre>
{scanResult.names}
</pre>
</div>
);
}
}
useEffect(() => {
loadInfo().catch(console.error);
}, []);
useEffect(() => {
if (info) {
const fileLink = info.metadata?.url ?? `${ApiHost}/d/${info.id}`;
setFileSize(info.metadata?.size ?? 0);
const order = window.localStorage.getItem(`payment-${info.id}`);
if (order) {
const orderObj = JSON.parse(order);
setOrder(orderObj);
setLink(`${fileLink}?orderId=${orderObj.id}`);
} else {
setLink(fileLink);
}
}
}, [info]);
function renderEncryptedDownload() {
if (!isFileEncrypted() || isDecrypted() || isPaymentRequired()) return;
return ( return (
<div className="preview page"> <div className="encrypted">
{info ? ( <h3>This file is encrypted, please enter the encryption key:</h3>
<Fragment> <input
<Helmet> type="password"
<title>void.cat - {info.metadata?.name ?? info.id}</title> placeholder="Encryption key"
{info.metadata?.description ? value={key}
<meta name="description" content={info.metadata?.description}/> : null} onChange={(e) => setKey(e.target.value)}
{renderOpenGraphTags()} />
</Helmet> <VoidButton onClick={() => decryptFile()}>Decrypt</VoidButton>
{renderVirusWarning()} {progress > 0 &&
<div className="flex flex-center"> `${(100 * progress).toFixed(0)}% (${FormatBytes(speed)}/s)`}
<div className="flx-grow"> {error && <h4 className="error">{error}</h4>}
{info.uploader ? <InlineProfile profile={info.uploader}/> : null} </div>
</div>
<div>
{canAccessFile() &&
<>
<a className="btn" href={info?.metadata?.magnetLink}>
<Icon name="link" size={14} className="mr10"/>
Magnet
</a>
<a className="btn" href={`${link}.torrent`}
download={info.metadata?.name ?? info.id}>
<Icon name="file" size={14} className="mr10"/>
Torrent
</a>
<a className="btn" href={link}
download={info.metadata?.name ?? info.id}>
<Icon name="download" size={14} className="mr10"/>
Direct Download
</a>
</>}
</div>
</div>
{renderPayment()}
{renderPreview()}
{renderEncryptedDownload()}
<div className="file-stats">
<div>
<Icon name="download-cloud"/>
{FormatBytes(info?.bandwidth?.egress ?? 0, 2)}
</div>
<div>
<Icon name="save"/>
{FormatBytes(info?.metadata?.size ?? 0, 2)}
</div>
</div>
<FileEdit file={info}/>
</Fragment>
) : "Not Found"}
</div>
); );
}
function renderPayment() {
if (!info) return;
if (info.payment && info.payment.service !== 0 && !order) {
return <FilePayment file={info} onPaid={loadInfo} />;
}
}
function renderPreview() {
if (!canAccessFile() || !info) return;
if (info.metadata) {
switch (info.metadata.mimeType) {
case "image/avif":
case "image/bmp":
case "image/gif":
case "image/svg+xml":
case "image/tiff":
case "image/webp":
case "image/jpg":
case "image/jpeg":
case "image/png": {
return <img src={link} alt={info.metadata.name} />;
}
case "audio/aac":
case "audio/opus":
case "audio/wav":
case "audio/webm":
case "audio/midi":
case "audio/mpeg":
case "audio/ogg": {
return <audio src={link} controls />;
}
case "video/x-msvideo":
case "video/mpeg":
case "video/ogg":
case "video/mp2t":
case "video/mp4":
case "video/matroksa":
case "video/x-matroska":
case "video/webm":
case "video/quicktime": {
return <video src={link} controls />;
}
case "application/json":
case "text/javascript":
case "text/html":
case "text/csv":
case "text/css":
case "text/plain": {
return <TextPreview link={link} />;
}
case "application/pdf": {
return <object data={link} />;
}
default: {
return <h3>{info.metadata?.name ?? info.id}</h3>;
}
}
}
}
function renderOpenGraphTags() {
const tags = [
<meta
key="og-site_name"
property={"og:site_name"}
content={"void.cat"}
/>,
<meta
key="og-title"
property={"og:title"}
content={info?.metadata?.name}
/>,
<meta
key="og-description"
property={"og:description"}
content={info?.metadata?.description}
/>,
<meta
key="og-url"
property={"og:url"}
content={`https://${window.location.host}/${info?.id}`}
/>,
];
const mime = info?.metadata?.mimeType;
if (mime?.startsWith("image/")) {
tags.push(<meta key="og-image" property={"og:image"} content={link} />);
tags.push(
<meta key="og-image-type" property={"og:image:type"} content={mime} />,
);
} else if (mime?.startsWith("video/")) {
tags.push(<meta key="og-video" property={"og:video"} content={link} />);
tags.push(
<meta key="og-video-type" property={"og:video:type"} content={mime} />,
);
} else if (mime?.startsWith("audio/")) {
tags.push(<meta key="og-audio" property={"og:audio"} content={link} />);
tags.push(
<meta key="og-audio-type" property={"og:audio:type"} content={mime} />,
);
}
return tags;
}
function renderVirusWarning() {
if (info?.virusScan?.isVirus === true) {
let scanResult = info.virusScan;
return (
<div className="virus-warning">
<p>
This file apears to be a virus, take care when downloading this
file.
</p>
Detected as:
<pre>{scanResult.names}</pre>
</div>
);
}
}
useEffect(() => {
loadInfo().catch(console.error);
}, []);
useEffect(() => {
if (info) {
const fileLink = info.metadata?.url ?? `${ApiHost}/d/${info.id}`;
setFileSize(info.metadata?.size ?? 0);
const order = window.localStorage.getItem(`payment-${info.id}`);
if (order) {
const orderObj = JSON.parse(order);
setOrder(orderObj);
setLink(`${fileLink}?orderId=${orderObj.id}`);
} else {
setLink(fileLink);
}
}
}, [info]);
return (
<div className="preview page">
{info ? (
<Fragment>
<Helmet>
<title>void.cat - {info.metadata?.name ?? info.id}</title>
{info.metadata?.description ? (
<meta name="description" content={info.metadata?.description} />
) : null}
{renderOpenGraphTags()}
</Helmet>
{renderVirusWarning()}
<div className="flex flex-center">
<div className="flx-grow">
{info.uploader ? <InlineProfile profile={info.uploader} /> : null}
</div>
<div>
{canAccessFile() && (
<>
<a className="btn" href={info?.metadata?.magnetLink}>
<Icon name="link" size={14} className="mr10" />
Magnet
</a>
<a
className="btn"
href={`${link}.torrent`}
download={info.metadata?.name ?? info.id}
>
<Icon name="file" size={14} className="mr10" />
Torrent
</a>
<a
className="btn"
href={link}
download={info.metadata?.name ?? info.id}
>
<Icon name="download" size={14} className="mr10" />
Direct Download
</a>
</>
)}
</div>
</div>
{renderPayment()}
{renderPreview()}
{renderEncryptedDownload()}
<div className="file-stats">
<div>
<Icon name="download-cloud" />
{FormatBytes(info?.bandwidth?.egress ?? 0, 2)}
</div>
<div>
<Icon name="save" />
{FormatBytes(info?.metadata?.size ?? 0, 2)}
</div>
</div>
<FileEdit file={info} />
</Fragment>
) : (
"Not Found"
)}
</div>
);
} }

View File

@ -1,20 +1,20 @@
import {useSelector} from "react-redux"; import { useSelector } from "react-redux";
import {Dropzone} from "../Components/FileUpload/Dropzone"; import { Dropzone } from "../Components/FileUpload/Dropzone";
import {GlobalStats} from "../Components/HomePage/GlobalStats"; import { GlobalStats } from "../Components/HomePage/GlobalStats";
import {FooterLinks} from "../Components/HomePage/FooterLinks"; import { FooterLinks } from "../Components/HomePage/FooterLinks";
import {MetricsGraph} from "../Components/HomePage/MetricsGraph"; import { MetricsGraph } from "../Components/HomePage/MetricsGraph";
import {RootState} from "Store"; import { RootState } from "Store";
export function HomePage() { export function HomePage() {
const metrics = useSelector((s: RootState) => s.info.info); const metrics = useSelector((s: RootState) => s.info.info);
return ( return (
<div className="page"> <div className="page">
<Dropzone/> <Dropzone />
<GlobalStats/> <GlobalStats />
<MetricsGraph metrics={metrics?.timeSeriesMetrics}/> <MetricsGraph metrics={metrics?.timeSeriesMetrics} />
<FooterLinks/> <FooterLinks />
</div> </div>
); );
} }

View File

@ -1,49 +1,48 @@
.profile { .profile {
} }
.profile .name { .profile .name {
font-size: 30px; font-size: 30px;
margin: 10px 0; margin: 10px 0;
} }
.profile .name input { .profile .name input {
background: unset; background: unset;
color: white; color: white;
font-size: inherit; font-size: inherit;
line-height: inherit; line-height: inherit;
border: unset; border: unset;
} }
.profile .avatar { .profile .avatar {
width: 256px; width: 256px;
height: 256px; height: 256px;
border-radius: 40px; border-radius: 40px;
background-position: center; background-position: center;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover; background-size: cover;
border: 1px solid; border: 1px solid;
} }
.profile .avatar .edit-avatar { .profile .avatar .edit-avatar {
opacity: 0; opacity: 0;
background: rgba(0, 0, 0, 0.4); background: rgba(0, 0, 0, 0.4);
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
} }
.profile .avatar .edit-avatar:hover { .profile .avatar .edit-avatar:hover {
opacity: 1; opacity: 1;
} }
.profile .roles > span { .profile .roles > span {
margin-right: 10px; margin-right: 10px;
} }
.profile dt { .profile dt {
font-weight: bold; font-weight: bold;
} }

View File

@ -1,198 +1,242 @@
import "./Profile.css"; import "./Profile.css";
import {Fragment, useState} from "react"; import { Fragment, useState } from "react";
import {useDispatch, useSelector} from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import {default as moment} from "moment"; import { default as moment } from "moment";
import {useLoaderData} from "react-router-dom"; import { useLoaderData } from "react-router-dom";
import {Profile} from "@void-cat/api"; import { Profile } from "@void-cat/api";
import useApi from "Hooks/UseApi"; import useApi from "Hooks/UseApi";
import {RootState} from "Store"; import { RootState } from "Store";
import {DefaultAvatar} from "Const"; import { DefaultAvatar } from "Const";
import {logout, setProfile as setGlobalProfile} from "../LoginState"; import { logout, setProfile as setGlobalProfile } from "../LoginState";
import {FileList} from "../Components/Shared/FileList"; import { FileList } from "../Components/Shared/FileList";
import {VoidButton} from "../Components/Shared/VoidButton"; import { VoidButton } from "../Components/Shared/VoidButton";
import ApiKeyList from "../Components/Profile/ApiKeyList"; import ApiKeyList from "../Components/Profile/ApiKeyList";
export function ProfilePage() { export function ProfilePage() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const loader = useLoaderData(); const loader = useLoaderData();
const Api = useApi(); const Api = useApi();
const [profile, setProfile] = useState(loader as Profile | null); const [profile, setProfile] = useState(loader as Profile | null);
const [emailCode, setEmailCode] = useState(""); const [emailCode, setEmailCode] = useState("");
const [emailCodeError, setEmailCodeError] = useState(""); const [emailCodeError, setEmailCodeError] = useState("");
const [newCodeSent, setNewCodeSent] = useState(false); const [newCodeSent, setNewCodeSent] = useState(false);
const localProfile = useSelector((s: RootState) => s.login.profile); const localProfile = useSelector((s: RootState) => s.login.profile);
const canEdit = localProfile?.id === profile?.id; const canEdit = localProfile?.id === profile?.id;
const needsEmailVerify = canEdit && profile?.needsVerification === true; const needsEmailVerify = canEdit && profile?.needsVerification === true;
const cantEditProfile = canEdit && !needsEmailVerify; const cantEditProfile = canEdit && !needsEmailVerify;
async function changeAvatar() { async function changeAvatar() {
const res = await new Promise<Array<File>>((resolve) => { const res = await new Promise<Array<File>>((resolve) => {
let i = document.createElement('input'); let i = document.createElement("input");
i.setAttribute('type', 'file'); i.setAttribute("type", "file");
i.setAttribute('multiple', ''); i.setAttribute("multiple", "");
i.addEventListener('change', async function (evt) { i.addEventListener("change", async function (evt) {
resolve((evt.target as any).files); resolve((evt.target as any).files);
}); });
i.click(); i.click();
}); });
const file = res[0];
const uploader = Api.getUploader(file);
const rsp = await uploader.upload();
if (rsp.ok) {
setProfile({
...profile,
avatar: rsp.file?.id
} as Profile);
}
const file = res[0];
const uploader = Api.getUploader(file);
const rsp = await uploader.upload();
if (rsp.ok) {
setProfile({
...profile,
avatar: rsp.file?.id,
} as Profile);
} }
}
async function saveUser(p: Profile) { async function saveUser(p: Profile) {
try { try {
await Api.updateUser(p); await Api.updateUser(p);
dispatch(setGlobalProfile(p)); dispatch(setGlobalProfile(p));
} catch (e) { } catch (e) {
console.error(e); console.error(e);
}
} }
}
async function submitCode(id: string, code: string) { async function submitCode(id: string, code: string) {
try { try {
await Api.submitVerifyCode(id, code); await Api.submitVerifyCode(id, code);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setEmailCodeError("Invalid or expired code."); setEmailCodeError("Invalid or expired code.");
}
} }
}
async function sendNewCode(id: string) { async function sendNewCode(id: string) {
setNewCodeSent(true); setNewCodeSent(true);
try { try {
await Api.sendNewCode(id); await Api.sendNewCode(id);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setNewCodeSent(false); setNewCodeSent(false);
}
} }
}
function renderEmailVerify() { function renderEmailVerify() {
if (!profile) return; if (!profile) return;
return ( return (
<Fragment> <Fragment>
<h2>Please enter email verification code</h2> <h2>Please enter email verification code</h2>
<small>Your account will automatically be deleted in 7 days if you do not verify your email <small>
address.</small> Your account will automatically be deleted in 7 days if you do not
<br/> verify your email address.
<input type="text" placeholder="Verification code" value={emailCode} </small>
onChange={(e) => setEmailCode(e.target.value)}/> <br />
<VoidButton onClick={() => submitCode(profile.id, emailCode)}>Submit</VoidButton> <input
<VoidButton onClick={() => { type="text"
dispatch(logout()); placeholder="Verification code"
}}>Logout</VoidButton> value={emailCode}
<br/> onChange={(e) => setEmailCode(e.target.value)}
{emailCodeError && <b>{emailCodeError}</b>} />
{(emailCodeError && !newCodeSent) && <VoidButton onClick={() => submitCode(profile.id, emailCode)}>
<a onClick={() => sendNewCode(profile.id)}>Send verification email</a>} Submit
</Fragment> </VoidButton>
); <VoidButton
onClick={() => {
dispatch(logout());
}}
>
Logout
</VoidButton>
<br />
{emailCodeError && <b>{emailCodeError}</b>}
{emailCodeError && !newCodeSent && (
<a onClick={() => sendNewCode(profile.id)}>Send verification email</a>
)}
</Fragment>
);
}
function renderProfileEdit() {
if (!profile) return;
return (
<Fragment>
<dl>
<dt>Public Profile:</dt>
<dd>
<input
type="checkbox"
checked={profile.publicProfile}
onChange={(e) =>
setProfile({
...profile,
publicProfile: e.target.checked,
})
}
/>
</dd>
<dt>Public Uploads:</dt>
<dd>
<input
type="checkbox"
checked={profile.publicUploads}
onChange={(e) =>
setProfile({
...profile,
publicUploads: e.target.checked,
})
}
/>
</dd>
</dl>
<div className="flex flex-center">
<div>
<VoidButton
onClick={() => saveUser(profile)}
options={{ showSuccess: true }}
>
Save
</VoidButton>
</div>
<div>
<VoidButton
onClick={() => {
dispatch(logout());
}}
>
Logout
</VoidButton>
</div>
</div>
</Fragment>
);
}
if (profile) {
let avatarUrl = profile.avatar ?? DefaultAvatar;
if (!avatarUrl.startsWith("http")) {
// assume void-cat hosted avatar
avatarUrl = `/d/${avatarUrl}`;
} }
let avatarStyles = {
function renderProfileEdit() { backgroundImage: `url(${avatarUrl})`,
if (!profile) return; };
return (
return ( <div className="page">
<Fragment> <div className="profile">
<dl> <div className="name">
<dt>Public Profile:</dt> {cantEditProfile ? (
<dd> <input
<input type="checkbox" checked={profile.publicProfile} value={profile.name}
onChange={(e) => setProfile({ onChange={(e) =>
...profile, setProfile({
publicProfile: e.target.checked ...profile,
})}/> name: e.target.value,
</dd> })
<dt>Public Uploads:</dt> }
<dd> />
<input type="checkbox" checked={profile.publicUploads} ) : (
onChange={(e) => setProfile({ profile.name
...profile, )}
publicUploads: e.target.checked </div>
})}/> <div className="flex">
</dd> <div className="flx-1">
<div className="avatar" style={avatarStyles}>
</dl> {cantEditProfile ? (
<div className="flex flex-center"> <div className="edit-avatar" onClick={() => changeAvatar()}>
<div> <h3>Edit</h3>
<VoidButton onClick={() => saveUser(profile)} options={{showSuccess: true}}>Save</VoidButton> </div>
</div> ) : null}
<div> </div>
<VoidButton onClick={() => {
dispatch(logout());
}}>Logout</VoidButton>
</div>
</div>
</Fragment>
);
}
if (profile) {
let avatarUrl = profile.avatar ?? DefaultAvatar;
if (!avatarUrl.startsWith("http")) {
// assume void-cat hosted avatar
avatarUrl = `/d/${avatarUrl}`;
}
let avatarStyles = {
backgroundImage: `url(${avatarUrl})`
};
return (
<div className="page">
<div className="profile">
<div className="name">
{cantEditProfile ?
<input value={profile.name}
onChange={(e) => setProfile({
...profile,
name: e.target.value
})}/>
: profile.name}
</div>
<div className="flex">
<div className="flx-1">
<div className="avatar" style={avatarStyles}>
{cantEditProfile ? <div className="edit-avatar" onClick={() => changeAvatar()}>
<h3>Edit</h3>
</div> : null}
</div>
</div>
<div className="flx-1">
<dl>
<dt>Created</dt>
<dd>{moment(profile.created).fromNow()}</dd>
<dt>Roles</dt>
<dd>{profile.roles.map(a => <span key={a} className="btn">{a}</span>)}</dd>
</dl>
</div>
</div>
{cantEditProfile ? renderProfileEdit() : null}
{needsEmailVerify ? renderEmailVerify() : null}
<h1>Uploads</h1>
<FileList loadPage={(req) => Api.listUserFiles(profile.id, req)}/>
{cantEditProfile ? <ApiKeyList/> : null}
</div>
</div> </div>
); <div className="flx-1">
} else { <dl>
return ( <dt>Created</dt>
<div className="page"> <dd>{moment(profile.created).fromNow()}</dd>
<h1>Loading..</h1> <dt>Roles</dt>
<dd>
{profile.roles.map((a) => (
<span key={a} className="btn">
{a}
</span>
))}
</dd>
</dl>
</div> </div>
); </div>
} {cantEditProfile ? renderProfileEdit() : null}
{needsEmailVerify ? renderEmailVerify() : null}
<h1>Uploads</h1>
<FileList loadPage={(req) => Api.listUserFiles(profile.id, req)} />
{cantEditProfile ? <ApiKeyList /> : null}
</div>
</div>
);
} else {
return (
<div className="page">
<h1>Loading..</h1>
</div>
);
}
} }

View File

@ -1,23 +1,23 @@
import {useSelector} from "react-redux"; import { useSelector } from "react-redux";
import {useNavigate} from "react-router-dom"; import { useNavigate } from "react-router-dom";
import {useEffect} from "react"; import { useEffect } from "react";
import {Login} from "../Components/Shared/Login"; import { Login } from "../Components/Shared/Login";
import {RootState} from "Store"; import { RootState } from "Store";
export function UserLogin() { export function UserLogin() {
const auth = useSelector((s: RootState) => s.login.jwt); const auth = useSelector((s: RootState) => s.login.jwt);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
if (auth) { if (auth) {
navigate("/"); navigate("/");
} }
}, [auth, navigate]); }, [auth, navigate]);
return ( return (
<div className="page"> <div className="page">
<Login/> <Login />
</div> </div>
) );
} }

View File

@ -1,17 +1,17 @@
import {createSlice, PayloadAction} from "@reduxjs/toolkit"; import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import {SiteInfoResponse} from "@void-cat/api"; import { SiteInfoResponse } from "@void-cat/api";
export const SiteInfoState = createSlice({ export const SiteInfoState = createSlice({
name: "SiteInfo", name: "SiteInfo",
initialState: { initialState: {
info: null as SiteInfoResponse | null info: null as SiteInfoResponse | null,
},
reducers: {
setInfo: (state, action: PayloadAction<SiteInfoResponse>) => {
state.info = action.payload;
}, },
reducers: { },
setInfo: (state, action: PayloadAction<SiteInfoResponse>) => {
state.info = action.payload;
},
}
}); });
export const {setInfo} = SiteInfoState.actions; export const { setInfo } = SiteInfoState.actions;
export default SiteInfoState.reducer; export default SiteInfoState.reducer;

View File

@ -1,12 +1,12 @@
import {configureStore} from "@reduxjs/toolkit"; import { configureStore } from "@reduxjs/toolkit";
import loginReducer from "./LoginState"; import loginReducer from "./LoginState";
import siteInfoReducer from "./SiteInfoStore"; import siteInfoReducer from "./SiteInfoStore";
const store = configureStore({ const store = configureStore({
reducer: { reducer: {
login: loginReducer, login: loginReducer,
info: siteInfoReducer info: siteInfoReducer,
} },
}); });
export type RootState = ReturnType<typeof store.getState>; export type RootState = ReturnType<typeof store.getState>;

View File

@ -7,69 +7,63 @@ import * as Const from "Const";
* @returns Bytes formatted in binary notation * @returns Bytes formatted in binary notation
*/ */
export function FormatBytes(b: number, f?: number) { export function FormatBytes(b: number, f?: number) {
f ??= 2; f ??= 2;
if (b >= Const.YiB) if (b >= Const.YiB) return (b / Const.YiB).toFixed(f) + " YiB";
return (b / Const.YiB).toFixed(f) + ' YiB'; if (b >= Const.ZiB) return (b / Const.ZiB).toFixed(f) + " ZiB";
if (b >= Const.ZiB) if (b >= Const.EiB) return (b / Const.EiB).toFixed(f) + " EiB";
return (b / Const.ZiB).toFixed(f) + ' ZiB'; if (b >= Const.PiB) return (b / Const.PiB).toFixed(f) + " PiB";
if (b >= Const.EiB) if (b >= Const.TiB) return (b / Const.TiB).toFixed(f) + " TiB";
return (b / Const.EiB).toFixed(f) + ' EiB'; if (b >= Const.GiB) return (b / Const.GiB).toFixed(f) + " GiB";
if (b >= Const.PiB) if (b >= Const.MiB) return (b / Const.MiB).toFixed(f) + " MiB";
return (b / Const.PiB).toFixed(f) + ' PiB'; if (b >= Const.kiB) return (b / Const.kiB).toFixed(f) + " KiB";
if (b >= Const.TiB) return b.toFixed(f) + " B";
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'
} }
export function buf2hex(buffer: number[] | ArrayBuffer) { export function buf2hex(buffer: number[] | ArrayBuffer) {
return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join(''); return [...new Uint8Array(buffer)]
.map((x) => x.toString(16).padStart(2, "0"))
.join("");
} }
export function ConstName(type: object, val: any) { export function ConstName(type: object, val: any) {
for (let [k, v] of Object.entries(type)) { for (let [k, v] of Object.entries(type)) {
if (v === val) { if (v === val) {
return k; return k;
}
} }
}
} }
export function FormatCurrency(value: number, currency: string | number) { export function FormatCurrency(value: number, currency: string | number) {
switch (currency) { switch (currency) {
case 0: case 0:
case "BTC": { case "BTC": {
let hasDecimals = (value % 1) > 0; let hasDecimals = value % 1 > 0;
return `${value.toLocaleString(undefined, { return `${value.toLocaleString(undefined, {
minimumFractionDigits: hasDecimals ? 8 : 0, // Sats minimumFractionDigits: hasDecimals ? 8 : 0, // Sats
maximumFractionDigits: 11 // MSats maximumFractionDigits: 11, // MSats
})}`; })}`;
}
case 1:
case "USD": {
return value.toLocaleString(undefined, {
style: "currency",
currency: "USD"
});
}
case 2:
case "EUR": {
return value.toLocaleString(undefined, {
style: "currency",
currency: "EUR"
});
}
case 3:
case "GBP": {
return value.toLocaleString(undefined, {
style: "currency",
currency: "GBP"
});
}
} }
return value.toString(); case 1:
case "USD": {
return value.toLocaleString(undefined, {
style: "currency",
currency: "USD",
});
}
case 2:
case "EUR": {
return value.toLocaleString(undefined, {
style: "currency",
currency: "EUR",
});
}
case 3:
case "GBP": {
return value.toLocaleString(undefined, {
style: "currency",
currency: "GBP",
});
}
}
return value.toString();
} }

View File

@ -1,104 +1,109 @@
@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;700&display=swap'); @import url("https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;700&display=swap");
body { body {
margin: 0; margin: 0;
font-family: 'Source Code Pro', monospace; font-family: "Source Code Pro", monospace;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: black; background-color: black;
color: white; color: white;
} }
a { a {
color: white; color: white;
text-decoration: none; text-decoration: none;
} }
a:hover { a:hover {
text-decoration: underline; text-decoration: underline;
} }
.btn { .btn {
display: inline-block; display: inline-block;
line-height: 1.1; line-height: 1.1;
font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
border-radius: 10px; border-radius: 10px;
background-color: white; background-color: white;
color: black; color: black;
padding: 10px 20px; padding: 10px 20px;
user-select: none; user-select: none;
cursor: pointer; cursor: pointer;
margin: 5px; margin: 5px;
} }
.btn.disabled { .btn.disabled {
background-color: #666; background-color: #666;
} }
.flex { .flex {
display: flex; display: flex;
} }
.flex-inline { .flex-inline {
display: inline-flex; display: inline-flex;
} }
.flx-1 { .flx-1 {
flex: 1; flex: 1;
} }
.flx-2 { .flx-2 {
flex: 2; flex: 2;
} }
.flx-grow { .flx-grow {
flex-grow: 1; flex-grow: 1;
} }
.flex-center { .flex-center {
align-items: center; align-items: center;
} }
input[type="text"], input[type="number"], input[type="password"], input[type="datetime-local"], input[type="checkbox"], select { input[type="text"],
display: inline-block; input[type="number"],
line-height: 1.1; input[type="password"],
border-radius: 10px; input[type="datetime-local"],
padding: 10px 20px; input[type="checkbox"],
margin: 5px; select {
border: 0; display: inline-block;
line-height: 1.1;
border-radius: 10px;
padding: 10px 20px;
margin: 5px;
border: 0;
} }
input[type="checkbox"] { input[type="checkbox"] {
width: 20px; width: 20px;
height: 20px; height: 20px;
} }
table { table {
width: 100%; width: 100%;
word-break: keep-all; word-break: keep-all;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
border-collapse: collapse; border-collapse: collapse;
} }
table tr:nth-child(2n) { table tr:nth-child(2n) {
background-color: #111; background-color: #111;
} }
table th { table th {
background-color: #222; background-color: #222;
text-align: start; text-align: start;
} }
pre.copy { pre.copy {
user-select: all; user-select: all;
width: fit-content; width: fit-content;
border-radius: 4px; border-radius: 4px;
border: 1px solid; border: 1px solid;
padding: 5px; padding: 5px;
} }
.mr10 { .mr10 {
margin-right: 10px; margin-right: 10px;
} }

View File

@ -1,12 +1,14 @@
import React from 'react'; import React from "react";
import ReactDOM from 'react-dom/client'; import ReactDOM from "react-dom/client";
import './index.css'; import "./index.css";
import App from './App'; import App from "./App";
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); const root = ReactDOM.createRoot(
root.render( document.getElementById("root") as HTMLElement,
<React.StrictMode> );
<App/> root.render(
</React.StrictMode> <React.StrictMode>
<App />
</React.StrictMode>,
); );

View File

@ -1,18 +1,18 @@
const {createProxyMiddleware} = require('http-proxy-middleware'); const { createProxyMiddleware } = require("http-proxy-middleware");
const settings = require("../package.json"); const settings = require("../package.json");
module.exports = function (app) { module.exports = function (app) {
const proxy = createProxyMiddleware({ const proxy = createProxyMiddleware({
target: settings.proxy, target: settings.proxy,
changeOrigin: true, changeOrigin: true,
secure: false secure: false,
}); });
app.use('/admin', proxy); app.use("/admin", proxy);
app.use('/d', proxy); app.use("/d", proxy);
app.use('/info', proxy); app.use("/info", proxy);
app.use('/upload', proxy); app.use("/upload", proxy);
app.use('/auth', proxy); app.use("/auth", proxy);
app.use('/swagger', proxy); app.use("/swagger", proxy);
app.use('/user', proxy); app.use("/user", proxy);
}; };

View File

@ -1841,6 +1841,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@eslint/js@npm:8.51.0":
version: 8.51.0
resolution: "@eslint/js@npm:8.51.0"
checksum: 0228bf1e1e0414843e56d9ff362a2a72d579c078f93174666f29315690e9e30a8633ad72c923297f7fd7182381b5a476805ff04dac8debe638953eb1ded3ac73
languageName: node
linkType: hard
"@eslint/js@npm:^8.47.0": "@eslint/js@npm:^8.47.0":
version: 8.47.0 version: 8.47.0
resolution: "@eslint/js@npm:8.47.0" resolution: "@eslint/js@npm:8.47.0"
@ -1871,6 +1878,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@humanwhocodes/config-array@npm:^0.11.11":
version: 0.11.11
resolution: "@humanwhocodes/config-array@npm:0.11.11"
dependencies:
"@humanwhocodes/object-schema": ^1.2.1
debug: ^4.1.1
minimatch: ^3.0.5
checksum: db84507375ab77b8ffdd24f498a5b49ad6b64391d30dd2ac56885501d03964d29637e05b1ed5aefa09d57ac667e28028bc22d2da872bfcd619652fbdb5f4ca19
languageName: node
linkType: hard
"@humanwhocodes/config-array@npm:^0.11.8": "@humanwhocodes/config-array@npm:^0.11.8":
version: 0.11.8 version: 0.11.8
resolution: "@humanwhocodes/config-array@npm:0.11.8" resolution: "@humanwhocodes/config-array@npm:0.11.8"
@ -6422,6 +6440,53 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"eslint@npm:^8.51.0":
version: 8.51.0
resolution: "eslint@npm:8.51.0"
dependencies:
"@eslint-community/eslint-utils": ^4.2.0
"@eslint-community/regexpp": ^4.6.1
"@eslint/eslintrc": ^2.1.2
"@eslint/js": 8.51.0
"@humanwhocodes/config-array": ^0.11.11
"@humanwhocodes/module-importer": ^1.0.1
"@nodelib/fs.walk": ^1.2.8
ajv: ^6.12.4
chalk: ^4.0.0
cross-spawn: ^7.0.2
debug: ^4.3.2
doctrine: ^3.0.0
escape-string-regexp: ^4.0.0
eslint-scope: ^7.2.2
eslint-visitor-keys: ^3.4.3
espree: ^9.6.1
esquery: ^1.4.2
esutils: ^2.0.2
fast-deep-equal: ^3.1.3
file-entry-cache: ^6.0.1
find-up: ^5.0.0
glob-parent: ^6.0.2
globals: ^13.19.0
graphemer: ^1.4.0
ignore: ^5.2.0
imurmurhash: ^0.1.4
is-glob: ^4.0.0
is-path-inside: ^3.0.3
js-yaml: ^4.1.0
json-stable-stringify-without-jsonify: ^1.0.1
levn: ^0.4.1
lodash.merge: ^4.6.2
minimatch: ^3.1.2
natural-compare: ^1.4.0
optionator: ^0.9.3
strip-ansi: ^6.0.1
text-table: ^0.2.0
bin:
eslint: bin/eslint.js
checksum: 214fa5d1fcb67af1b8992ce9584ccd85e1aa7a482f8b8ea5b96edc28fa838a18a3b69456db45fc1ed3ef95f1e9efa9714f737292dc681e572d471d02fda9649c
languageName: node
linkType: hard
"espree@npm:^9.5.2": "espree@npm:^9.5.2":
version: 9.5.2 version: 9.5.2
resolution: "espree@npm:9.5.2" resolution: "espree@npm:9.5.2"
@ -10975,6 +11040,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"prettier@npm:^3.0.3":
version: 3.0.3
resolution: "prettier@npm:3.0.3"
bin:
prettier: bin/prettier.cjs
checksum: e10b9af02b281f6c617362ebd2571b1d7fc9fb8a3bd17e371754428cda992e5e8d8b7a046e8f7d3e2da1dcd21aa001e2e3c797402ebb6111b5cd19609dd228e0
languageName: node
linkType: hard
"pretty-bytes@npm:^5.3.0, pretty-bytes@npm:^5.4.1": "pretty-bytes@npm:^5.3.0, pretty-bytes@npm:^5.4.1":
version: 5.6.0 version: 5.6.0
resolution: "pretty-bytes@npm:5.6.0" resolution: "pretty-bytes@npm:5.6.0"
@ -11893,6 +11967,10 @@ __metadata:
"root-workspace-0b6124@workspace:.": "root-workspace-0b6124@workspace:.":
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "root-workspace-0b6124@workspace:." resolution: "root-workspace-0b6124@workspace:."
dependencies:
eslint: ^8.51.0
prettier: ^3.0.3
typescript: ^5.2.2
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@ -13195,6 +13273,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"typescript@npm:^5.2.2":
version: 5.2.2
resolution: "typescript@npm:5.2.2"
bin:
tsc: bin/tsc
tsserver: bin/tsserver
checksum: 7912821dac4d962d315c36800fe387cdc0a6298dba7ec171b350b4a6e988b51d7b8f051317786db1094bd7431d526b648aba7da8236607febb26cf5b871d2d3c
languageName: node
linkType: hard
"typescript@patch:typescript@^5.0.4#~builtin<compat/typescript>": "typescript@patch:typescript@^5.0.4#~builtin<compat/typescript>":
version: 5.0.4 version: 5.0.4
resolution: "typescript@patch:typescript@npm%3A5.0.4#~builtin<compat/typescript>::version=5.0.4&hash=b5f058" resolution: "typescript@patch:typescript@npm%3A5.0.4#~builtin<compat/typescript>::version=5.0.4&hash=b5f058"
@ -13215,6 +13303,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"typescript@patch:typescript@^5.2.2#~builtin<compat/typescript>":
version: 5.2.2
resolution: "typescript@patch:typescript@npm%3A5.2.2#~builtin<compat/typescript>::version=5.2.2&hash=f3b441"
bin:
tsc: bin/tsc
tsserver: bin/tsserver
checksum: 0f4da2f15e6f1245e49db15801dbee52f2bbfb267e1c39225afdab5afee1a72839cd86000e65ee9d7e4dfaff12239d28beaf5ee431357fcced15fb08583d72ca
languageName: node
linkType: hard
"unbox-primitive@npm:^1.0.2": "unbox-primitive@npm:^1.0.2":
version: 1.0.2 version: 1.0.2
resolution: "unbox-primitive@npm:1.0.2" resolution: "unbox-primitive@npm:1.0.2"