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

View File

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

View File

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

View File

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

View File

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

View File

@ -55,6 +55,7 @@ export class StreamUploader extends VoidUploader {
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,
@ -65,9 +66,9 @@ export class StreamUploader extends VoidUploader {
reqHeaders["V-EncryptionParams"] = JSON.stringify(this.#encrypt!.getParams());
}
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",
mode: "cors",
body: this.#encrypt ? rsBase.pipeThrough(this.#encrypt!.getEncryptionTransform()) : rsBase,

View File

@ -2,17 +2,19 @@ import { VoidUploadResult } from "./index";
import sjcl from "sjcl";
import { sjclcodec } from "./codecBytes";
import { buf2hex } from "./utils";
import { AuthHandler } from "./api";
/**
* Generic upload state
*/
export enum UploadState {
NotStarted,
Starting,
Hashing,
Uploading,
Done,
Failed,
Challenge
NotStarted,
Starting,
Hashing,
Uploading,
Done,
Failed,
Challenge,
}
export type StateChangeHandler = (s: UploadState) => void;
@ -23,71 +25,71 @@ export type ProxyChallengeHandler = (html: string) => void;
* Base file uploader class
*/
export abstract class VoidUploader {
protected uri: string;
protected file: File | Blob;
protected auth?: string;
protected maxChunkSize: number;
protected onStateChange?: StateChangeHandler;
protected onProgress?: ProgressHandler;
protected onProxyChallenge?: ProxyChallengeHandler;
protected uri: string;
protected file: File | Blob;
protected auth?: AuthHandler;
protected maxChunkSize: number;
protected onStateChange?: StateChangeHandler;
protected onProgress?: ProgressHandler;
protected onProxyChallenge?: ProxyChallengeHandler;
constructor(
uri: string,
file: File | Blob,
stateChange?: StateChangeHandler,
progress?: ProgressHandler,
proxyChallenge?: ProxyChallengeHandler,
auth?: string,
chunkSize?: number
) {
this.uri = uri;
this.file = file;
this.onStateChange = stateChange;
this.onProgress = progress;
this.onProxyChallenge = proxyChallenge;
this.auth = auth;
this.maxChunkSize = chunkSize ?? Number.MAX_VALUE;
constructor(
uri: string,
file: File | Blob,
stateChange?: StateChangeHandler,
progress?: ProgressHandler,
proxyChallenge?: ProxyChallengeHandler,
auth?: AuthHandler,
chunkSize?: number
) {
this.uri = uri;
this.file = file;
this.onStateChange = stateChange;
this.onProgress = progress;
this.onProxyChallenge = proxyChallenge;
this.auth = auth;
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
* @param file
* @protected
*/
protected async digest(file: Blob) {
const ChunkSize = 1024 * 1024;
/**
* Upload a file to the API
* @param headers any additional headers to send with the request
*/
abstract upload(headers?: HeadersInit): Promise<VoidUploadResult>;
// 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()));
}
/**
* Can we use local encryption
*/
abstract canEncrypt(): boolean;
/**
* Upload a file to the API
* @param headers any additional headers to send with the request
*/
abstract upload(headers?: HeadersInit): Promise<VoidUploadResult>;
/**
* Enable/Disable encryption, file will be encrypted on the fly locally before uploading
*/
abstract setEncryption(s: boolean): void;
/**
* Can we use local encryption
*/
abstract canEncrypt(): boolean;
/**
* 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;
}
/**
* 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";
export class XHRUploader extends VoidUploader {
canEncrypt(): boolean {
return false;
}
canEncrypt(): boolean {
return false;
}
setEncryption() {
//noop
}
setEncryption() {
//noop
}
getEncryptionKey() {
return undefined;
}
getEncryptionKey() {
return undefined;
}
async upload(headers?: HeadersInit): Promise<VoidUploadResult> {
this.onStateChange?.(UploadState.Hashing);
const hash = await this.digest(this.file);
if (this.file.size > this.maxChunkSize) {
return await this.#doSplitXHRUpload(hash, this.maxChunkSize, headers);
} else {
return await this.#xhrSegment(this.file, hash, undefined, undefined, 1, 1, headers);
}
async upload(headers?: HeadersInit): Promise<VoidUploadResult> {
this.onStateChange?.(UploadState.Hashing);
const hash = await this.digest(this.file);
if (this.file.size > this.maxChunkSize) {
return await this.#doSplitXHRUpload(hash, this.maxChunkSize, headers);
} else {
return await this.#xhrSegment(
this.file,
hash,
undefined,
undefined,
1,
1,
headers
);
}
}
async #doSplitXHRUpload(hash: string, splitSize: number, headers?: HeadersInit) {
let xhr: VoidUploadResult | null = null;
const segments = Math.ceil(this.file.size / splitSize);
for (let s = 0; s < segments; s++) {
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;
async #doSplitXHRUpload(
hash: string,
splitSize: number,
headers?: HeadersInit
) {
let xhr: VoidUploadResult | null = null;
const segments = Math.ceil(this.file.size / splitSize);
for (let s = 0; s < segments; s++) {
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!;
}
/**
* 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);
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);
}
});
}
}
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);
}
});
}
}