This commit is contained in:
Kieran 2023-10-13 21:46:00 +01:00
parent 6f28c3f293
commit ef4ca27f4b
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
8 changed files with 373 additions and 281 deletions

View File

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.StaticFiles; using Microsoft.AspNetCore.StaticFiles;
@ -58,6 +59,8 @@ namespace VoidCat.Controllers
[HttpPost] [HttpPost]
[DisableRequestSizeLimit] [DisableRequestSizeLimit]
[DisableFormValueModelBinding] [DisableFormValueModelBinding]
[Authorize(AuthenticationSchemes = "Bearer,Nostr")]
[AllowAnonymous]
public async Task<IActionResult> UploadFile([FromQuery] bool cli = false) public async Task<IActionResult> UploadFile([FromQuery] bool cli = false)
{ {
try try

View File

@ -95,7 +95,7 @@ public class UserManager
} }
var uid = await _store.LookupUser(user.Email); var uid = await _store.LookupUser(user.Email);
if (uid.HasValue) if (uid.HasValue && uid.Value != Guid.Empty)
{ {
var existingUser = await _store.Get(uid.Value); var existingUser = await _store.Get(uid.Value);
if (existingUser?.AuthType == UserAuthType.OAuth2) if (existingUser?.AuthType == UserAuthType.OAuth2)
@ -119,7 +119,7 @@ public class UserManager
public async ValueTask<User> LoginOrRegister(string pubkey) public async ValueTask<User> LoginOrRegister(string pubkey)
{ {
var uid = await _store.LookupUser(pubkey); var uid = await _store.LookupUser(pubkey);
if (uid.HasValue) if (uid.HasValue && uid.Value != Guid.Empty)
{ {
var existingUser = await _store.Get(uid.Value); var existingUser = await _store.Get(uid.Value);
if (existingUser?.AuthType == UserAuthType.Nostr) if (existingUser?.AuthType == UserAuthType.Nostr)

View File

@ -140,7 +140,7 @@ public static class VoidStartup
services.AddAuthentication(o => services.AddAuthentication(o =>
{ {
o.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; o.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
o.AddScheme<NostrAuthHandler>(NostrAuth.Scheme, "Nostr"); o.AddScheme<NostrAuthHandler>(NostrAuth.Scheme, null);
}) })
.AddJwtBearer(options => .AddJwtBearer(options =>
{ {

View File

@ -1,6 +1,6 @@
{ {
"name": "@void-cat/api", "name": "@void-cat/api",
"version": "1.0.8", "version": "1.0.10",
"description": "void.cat API package", "description": "void.cat API package",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@ -1,147 +1,192 @@
import { import {
AdminProfile, AdminUserListResult, AdminProfile,
ApiError, ApiKey, AdminUserListResult,
LoginSession, ApiError,
PagedRequest, ApiKey,
PagedResponse, LoginSession,
PaymentOrder, PagedRequest,
Profile, SetPaymentConfigRequest, PagedResponse,
SiteInfoResponse, PaymentOrder,
VoidFileResponse Profile,
SetPaymentConfigRequest,
SiteInfoResponse,
VoidFileResponse,
} from "./index"; } from "./index";
import { ProgressHandler, ProxyChallengeHandler, StateChangeHandler, VoidUploader } from "./upload"; import {
ProgressHandler,
ProxyChallengeHandler,
StateChangeHandler,
VoidUploader,
} from "./upload";
import { StreamUploader } from "./stream-uploader"; import { StreamUploader } from "./stream-uploader";
import { XHRUploader } from "./xhr-uploader"; import { XHRUploader } from "./xhr-uploader";
export type AuthHandler = (url: string, method: string) => Promise<string>;
export class VoidApi { export class VoidApi {
readonly #uri: string readonly #uri: string;
readonly #auth?: string readonly #auth?: AuthHandler;
readonly #scheme?: string
constructor(uri: string, auth?: string, scheme?: string) { constructor(uri: string, auth?: AuthHandler) {
this.#uri = uri; this.#uri = uri;
this.#auth = auth; this.#auth = auth;
this.#scheme = scheme; }
async #req<T>(method: string, url: string, body?: object): Promise<T> {
const absoluteUrl = `${this.#uri}${url}`;
const headers: HeadersInit = {
Accept: "application/json",
};
if (this.#auth) {
headers["Authorization"] = await this.#auth(absoluteUrl, method);
}
if (body) {
headers["Content-Type"] = "application/json";
} }
async #req<T>(method: string, url: string, body?: object): Promise<T> { const res = await fetch(absoluteUrl, {
let headers: HeadersInit = { method,
"Accept": "application/json" headers,
}; mode: "cors",
if (this.#auth) { body: body ? JSON.stringify(body) : undefined,
headers["Authorization"] = `${this.#scheme ?? "Bearer"} ${this.#auth}`; });
} const text = await res.text();
if (body) { if (res.ok) {
headers["Content-Type"] = "application/json"; return text ? (JSON.parse(text) as T) : ({} as T);
} } else {
throw new ApiError(res.status, text);
const res = await fetch(`${this.#uri}${url}`, {
method,
headers,
mode: "cors",
body: body ? JSON.stringify(body) : undefined
});
const text = await res.text();
if (res.ok) {
return text ? JSON.parse(text) as T : {} as T;
} else {
throw new ApiError(res.status, text);
}
} }
}
/** /**
* Get uploader for uploading files * Get uploader for uploading files
*/ */
getUploader( getUploader(
file: File | Blob, file: File | Blob,
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(this.#uri, file, stateChange, progress, proxyChallenge, this.#auth, chunkSize); return new StreamUploader(
} else { this.#uri,
return new XHRUploader(this.#uri, file, stateChange, progress, proxyChallenge, this.#auth, chunkSize); file,
} stateChange,
progress,
proxyChallenge,
this.#auth,
chunkSize
);
} else {
return new XHRUploader(
this.#uri,
file,
stateChange,
progress,
proxyChallenge,
this.#auth,
chunkSize
);
} }
}
/** /**
* General site information * General site information
*/ */
info() { info() {
return this.#req<SiteInfoResponse>("GET", "/info") return this.#req<SiteInfoResponse>("GET", "/info");
} }
fileInfo(id: string) { fileInfo(id: string) {
return this.#req<VoidFileResponse>("GET", `/upload/${id}`); return this.#req<VoidFileResponse>("GET", `/upload/${id}`);
} }
setPaymentConfig(id: string, cfg: SetPaymentConfigRequest) { setPaymentConfig(id: string, cfg: SetPaymentConfigRequest) {
return this.#req("POST", `/upload/${id}/payment`, cfg); return this.#req("POST", `/upload/${id}/payment`, cfg);
} }
createOrder(id: string) { createOrder(id: string) {
return this.#req<PaymentOrder>("GET", `/upload/${id}/payment`); return this.#req<PaymentOrder>("GET", `/upload/${id}/payment`);
} }
getOrder(file: string, order: string) { getOrder(file: string, order: string) {
return this.#req<PaymentOrder>("GET", `/upload/${file}/payment/${order}`); return this.#req<PaymentOrder>("GET", `/upload/${file}/payment/${order}`);
} }
login(username: string, password: string, captcha?: string) { login(username: string, password: string, captcha?: string) {
return this.#req<LoginSession>("POST", `/auth/login`, { username, password, captcha }); return this.#req<LoginSession>("POST", `/auth/login`, {
} username,
password,
captcha,
});
}
register(username: string, password: string, captcha?: string) { register(username: string, password: string, captcha?: string) {
return this.#req<LoginSession>("POST", `/auth/register`, { username, password, captcha }); return this.#req<LoginSession>("POST", `/auth/register`, {
} username,
password,
captcha,
});
}
getUser(id: string) { getUser(id: string) {
return this.#req<Profile>("GET", `/user/${id}`); return this.#req<Profile>("GET", `/user/${id}`);
} }
updateUser(u: Profile) { updateUser(u: Profile) {
return this.#req<void>("POST", `/user/${u.id}`, u); return this.#req<void>("POST", `/user/${u.id}`, u);
} }
listUserFiles(uid: string, pageReq: PagedRequest) { listUserFiles(uid: string, pageReq: PagedRequest) {
return this.#req<PagedResponse<VoidFileResponse>>("POST", `/user/${uid}/files`, pageReq); return this.#req<PagedResponse<VoidFileResponse>>(
} "POST",
`/user/${uid}/files`,
pageReq
);
}
submitVerifyCode(uid: string, code: string) { submitVerifyCode(uid: string, code: string) {
return this.#req<void>("POST", `/user/${uid}/verify`, { code }); return this.#req<void>("POST", `/user/${uid}/verify`, { code });
} }
sendNewCode(uid: string) { sendNewCode(uid: string) {
return this.#req<void>("GET", `/user/${uid}/verify`); return this.#req<void>("GET", `/user/${uid}/verify`);
} }
updateFileMetadata(id: string, meta: any) { updateFileMetadata(id: string, meta: any) {
return this.#req<void>("POST", `/upload/${id}/meta`, meta); return this.#req<void>("POST", `/upload/${id}/meta`, meta);
} }
listApiKeys() { listApiKeys() {
return this.#req<Array<ApiKey>>("GET", `/auth/api-key`); return this.#req<Array<ApiKey>>("GET", `/auth/api-key`);
} }
createApiKey(req: any) { createApiKey(req: any) {
return this.#req<ApiKey>("POST", `/auth/api-key`, req); return this.#req<ApiKey>("POST", `/auth/api-key`, req);
} }
adminListFiles(pageReq: PagedRequest) { adminListFiles(pageReq: PagedRequest) {
return this.#req<PagedResponse<VoidFileResponse>>("POST", "/admin/file", pageReq); return this.#req<PagedResponse<VoidFileResponse>>(
} "POST",
"/admin/file",
pageReq
);
}
adminDeleteFile(id: string) { adminDeleteFile(id: string) {
return this.#req<void>("DELETE", `/admin/file/${id}`); return this.#req<void>("DELETE", `/admin/file/${id}`);
} }
adminUserList(pageReq: PagedRequest) { adminUserList(pageReq: PagedRequest) {
return this.#req<PagedResponse<AdminUserListResult>>("POST", `/admin/users`, pageReq); return this.#req<PagedResponse<AdminUserListResult>>(
} "POST",
`/admin/users`,
pageReq
);
}
adminUpdateUser(u: AdminProfile) { adminUpdateUser(u: AdminProfile) {
return this.#req<void>("POST", `/admin/update-user`, u); return this.#req<void>("POST", `/admin/update-user`, u);
} }
} }

View File

@ -55,6 +55,7 @@ export class StreamUploader extends VoidUploader {
highWaterMark: DefaultChunkSize highWaterMark: DefaultChunkSize
}); });
const absoluteUrl = `${this.uri}/upload`;
const reqHeaders = { const reqHeaders = {
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
"V-Content-Type": !this.file.type ? "application/octet-stream" : this.file.type, "V-Content-Type": !this.file.type ? "application/octet-stream" : this.file.type,
@ -65,9 +66,9 @@ export class StreamUploader extends VoidUploader {
reqHeaders["V-EncryptionParams"] = JSON.stringify(this.#encrypt!.getParams()); reqHeaders["V-EncryptionParams"] = JSON.stringify(this.#encrypt!.getParams());
} }
if (this.auth) { if (this.auth) {
reqHeaders["Authorization"] = `Bearer ${this.auth}`; reqHeaders["Authorization"] = await this.auth(absoluteUrl, "POST");
} }
const req = await fetch(`${this.uri}/upload`, { const req = await fetch(absoluteUrl, {
method: "POST", method: "POST",
mode: "cors", mode: "cors",
body: this.#encrypt ? rsBase.pipeThrough(this.#encrypt!.getEncryptionTransform()) : rsBase, body: this.#encrypt ? rsBase.pipeThrough(this.#encrypt!.getEncryptionTransform()) : rsBase,

View File

@ -2,17 +2,19 @@ import { VoidUploadResult } from "./index";
import sjcl from "sjcl"; import sjcl from "sjcl";
import { sjclcodec } from "./codecBytes"; import { sjclcodec } from "./codecBytes";
import { buf2hex } from "./utils"; import { buf2hex } from "./utils";
import { AuthHandler } from "./api";
/** /**
* Generic upload state * Generic upload state
*/ */
export enum UploadState { export enum UploadState {
NotStarted, NotStarted,
Starting, Starting,
Hashing, Hashing,
Uploading, Uploading,
Done, Done,
Failed, Failed,
Challenge Challenge,
} }
export type StateChangeHandler = (s: UploadState) => void; export type StateChangeHandler = (s: UploadState) => void;
@ -23,71 +25,71 @@ export type ProxyChallengeHandler = (html: string) => void;
* Base file uploader class * Base file uploader class
*/ */
export abstract class VoidUploader { export abstract class VoidUploader {
protected uri: string; protected uri: string;
protected file: File | Blob; protected file: File | Blob;
protected auth?: string; protected auth?: AuthHandler;
protected maxChunkSize: number; protected maxChunkSize: number;
protected onStateChange?: StateChangeHandler; protected onStateChange?: StateChangeHandler;
protected onProgress?: ProgressHandler; protected onProgress?: ProgressHandler;
protected onProxyChallenge?: ProxyChallengeHandler; protected onProxyChallenge?: ProxyChallengeHandler;
constructor( constructor(
uri: string, uri: string,
file: File | Blob, file: File | Blob,
stateChange?: StateChangeHandler, stateChange?: StateChangeHandler,
progress?: ProgressHandler, progress?: ProgressHandler,
proxyChallenge?: ProxyChallengeHandler, proxyChallenge?: ProxyChallengeHandler,
auth?: string, auth?: AuthHandler,
chunkSize?: number chunkSize?: number
) { ) {
this.uri = uri; this.uri = uri;
this.file = file; this.file = file;
this.onStateChange = stateChange; this.onStateChange = stateChange;
this.onProgress = progress; this.onProgress = progress;
this.onProxyChallenge = proxyChallenge; this.onProxyChallenge = proxyChallenge;
this.auth = auth; this.auth = auth;
this.maxChunkSize = chunkSize ?? Number.MAX_VALUE; this.maxChunkSize = chunkSize ?? Number.MAX_VALUE;
}
/**
* SHA-256 hash the entire blob
* @param file
* @protected
*/
protected async digest(file: Blob) {
const ChunkSize = 1024 * 1024;
// must compute hash in chunks, subtle crypto cannot hash files > 2Gb
const sha = new sjcl.hash.sha256();
let progress = 0;
for (let x = 0; x < Math.ceil(file.size / ChunkSize); x++) {
const offset = x * ChunkSize;
const slice = file.slice(offset, offset + ChunkSize);
const chunk = await slice.arrayBuffer();
sha.update(sjclcodec.toBits(new Uint8Array(chunk)));
this.onProgress?.((progress += chunk.byteLength));
} }
return buf2hex(sjclcodec.fromBits(sha.finalize()));
}
/** /**
* SHA-256 hash the entire blob * Upload a file to the API
* @param file * @param headers any additional headers to send with the request
* @protected */
*/ abstract upload(headers?: HeadersInit): Promise<VoidUploadResult>;
protected async digest(file: Blob) {
const ChunkSize = 1024 * 1024;
// must compute hash in chunks, subtle crypto cannot hash files > 2Gb /**
const sha = new sjcl.hash.sha256(); * Can we use local encryption
let progress = 0; */
for (let x = 0; x < Math.ceil(file.size / ChunkSize); x++) { abstract canEncrypt(): boolean;
const offset = x * ChunkSize;
const slice = file.slice(offset, offset + ChunkSize);
const chunk = await slice.arrayBuffer();
sha.update(sjclcodec.toBits(new Uint8Array(chunk)));
this.onProgress?.(progress += chunk.byteLength);
}
return buf2hex(sjclcodec.fromBits(sha.finalize()));
}
/** /**
* Upload a file to the API * Enable/Disable encryption, file will be encrypted on the fly locally before uploading
* @param headers any additional headers to send with the request */
*/ abstract setEncryption(s: boolean): void;
abstract upload(headers?: HeadersInit): Promise<VoidUploadResult>;
/** /**
* Can we use local encryption * Get the encryption key, should be called after enableEncryption()
*/ */
abstract canEncrypt(): boolean; abstract getEncryptionKey(): string | undefined;
}
/**
* Enable/Disable encryption, file will be encrypted on the fly locally before uploading
*/
abstract setEncryption(s: boolean): void;
/**
* Get the encryption key, should be called after enableEncryption()
*/
abstract getEncryptionKey(): string | undefined;
}

View File

@ -2,99 +2,140 @@ import { UploadState, VoidUploader } from "./upload";
import { VoidUploadResult } from "./index"; import { VoidUploadResult } from "./index";
export class XHRUploader extends VoidUploader { export class XHRUploader extends VoidUploader {
canEncrypt(): boolean { canEncrypt(): boolean {
return false; return false;
} }
setEncryption() { setEncryption() {
//noop //noop
} }
getEncryptionKey() { getEncryptionKey() {
return undefined; return undefined;
} }
async upload(headers?: HeadersInit): Promise<VoidUploadResult> { async upload(headers?: HeadersInit): Promise<VoidUploadResult> {
this.onStateChange?.(UploadState.Hashing); this.onStateChange?.(UploadState.Hashing);
const hash = await this.digest(this.file); const hash = await this.digest(this.file);
if (this.file.size > this.maxChunkSize) { if (this.file.size > this.maxChunkSize) {
return await this.#doSplitXHRUpload(hash, this.maxChunkSize, headers); return await this.#doSplitXHRUpload(hash, this.maxChunkSize, headers);
} else { } else {
return await this.#xhrSegment(this.file, hash, undefined, undefined, 1, 1, headers); return await this.#xhrSegment(
} this.file,
hash,
undefined,
undefined,
1,
1,
headers
);
} }
}
async #doSplitXHRUpload(hash: string, splitSize: number, headers?: HeadersInit) { async #doSplitXHRUpload(
let xhr: VoidUploadResult | null = null; hash: string,
const segments = Math.ceil(this.file.size / splitSize); splitSize: number,
for (let s = 0; s < segments; s++) { headers?: HeadersInit
const offset = s * splitSize; ) {
const slice = this.file.slice(offset, offset + splitSize, this.file.type); let xhr: VoidUploadResult | null = null;
xhr = await this.#xhrSegment(slice, hash, xhr?.file?.id, xhr?.file?.metadata?.editSecret, s + 1, segments, headers); const segments = Math.ceil(this.file.size / splitSize);
if (!xhr.ok) { for (let s = 0; s < segments; s++) {
break; const offset = s * splitSize;
const slice = this.file.slice(offset, offset + splitSize, this.file.type);
xhr = await this.#xhrSegment(
slice,
hash,
xhr?.file?.id,
xhr?.file?.metadata?.editSecret,
s + 1,
segments,
headers
);
if (!xhr.ok) {
break;
}
}
return xhr!;
}
/**
* Upload a segment of the file
* @param segment
* @param fullDigest Full file hash
* @param id
* @param editSecret
* @param part Segment number
* @param partOf Total number of segments
* @param headers
*/
async #xhrSegment(
segment: ArrayBuffer | Blob,
fullDigest: string,
id?: string,
editSecret?: string,
part?: number,
partOf?: number,
headers?: HeadersInit
) {
this.onStateChange?.(UploadState.Uploading);
const absoluteUrl = id ? `${this.uri}/upload/${id}` : `${this.uri}/upload`;
const authValue = this.auth
? await this.auth(absoluteUrl, "POST")
: undefined;
return await new Promise<VoidUploadResult>((resolve, reject) => {
try {
const req = new XMLHttpRequest();
req.onreadystatechange = () => {
if (req.readyState === XMLHttpRequest.DONE && req.status === 200) {
const rsp = JSON.parse(req.responseText) as VoidUploadResult;
resolve(rsp);
} else if (
req.readyState === XMLHttpRequest.DONE &&
req.status === 403
) {
const contentType = req.getResponseHeader("content-type");
if (contentType?.toLowerCase().trim().indexOf("text/html") === 0) {
this.onProxyChallenge?.(req.response);
this.onStateChange?.(UploadState.Challenge);
reject(new Error("CF Challenge"));
} }
}
};
req.upload.onprogress = (e) => {
if (e instanceof ProgressEvent) {
this.onProgress?.(e.loaded);
}
};
req.open("POST", absoluteUrl);
req.setRequestHeader("Content-Type", "application/octet-stream");
req.setRequestHeader(
"V-Content-Type",
!this.file.type ? "application/octet-stream" : this.file.type
);
req.setRequestHeader(
"V-Filename",
"name" in this.file ? this.file.name : ""
);
req.setRequestHeader("V-Full-Digest", fullDigest);
req.setRequestHeader("V-Segment", `${part}/${partOf}`);
if (authValue) {
req.withCredentials = true;
req.setRequestHeader("Authorization", authValue);
} }
return xhr!; if (editSecret) {
} req.setRequestHeader("V-EditSecret", editSecret);
}
/** if (headers) {
* Upload a segment of the file for (const [k, v] of Object.entries(headers)) {
* @param segment req.setRequestHeader(k, v);
* @param fullDigest Full file hash }
* @param id }
* @param editSecret req.send(segment);
* @param part Segment number } catch (e) {
* @param partOf Total number of segments reject(e);
* @param headers }
*/ });
async #xhrSegment(segment: ArrayBuffer | Blob, fullDigest: string, }
id?: string, editSecret?: string, part?: number, partOf?: number, headers?: HeadersInit) { }
this.onStateChange?.(UploadState.Uploading);
return await new Promise<VoidUploadResult>((resolve, reject) => {
try {
const req = new XMLHttpRequest();
req.onreadystatechange = () => {
if (req.readyState === XMLHttpRequest.DONE && req.status === 200) {
const rsp = JSON.parse(req.responseText) as VoidUploadResult;
resolve(rsp);
} else if (req.readyState === XMLHttpRequest.DONE && req.status === 403) {
const contentType = req.getResponseHeader("content-type");
if (contentType?.toLowerCase().trim().indexOf("text/html") === 0) {
this.onProxyChallenge?.(req.response);
this.onStateChange?.(UploadState.Challenge);
reject(new Error("CF Challenge"));
}
}
};
req.upload.onprogress = (e) => {
if (e instanceof ProgressEvent) {
this.onProgress?.(e.loaded);
}
};
req.open("POST", id ? `${this.uri}/upload/${id}` : `${this.uri}/upload`);
req.setRequestHeader("Content-Type", "application/octet-stream");
req.setRequestHeader("V-Content-Type", !this.file.type ? "application/octet-stream" : this.file.type);
req.setRequestHeader("V-Filename", "name" in this.file ? this.file.name : "");
req.setRequestHeader("V-Full-Digest", fullDigest);
req.setRequestHeader("V-Segment", `${part}/${partOf}`)
if (this.auth) {
req.withCredentials = true;
req.setRequestHeader("Authorization", `Bearer ${this.auth}`);
}
if (editSecret) {
req.setRequestHeader("V-EditSecret", editSecret);
}
if (headers) {
for (const [k, v] of Object.entries(headers)) {
req.setRequestHeader(k, v);
}
}
req.send(segment);
} catch (e) {
reject(e);
}
});
}
}