diff --git a/VoidCat/Controllers/UploadController.cs b/VoidCat/Controllers/UploadController.cs index fcf7159..c2d4d67 100644 --- a/VoidCat/Controllers/UploadController.cs +++ b/VoidCat/Controllers/UploadController.cs @@ -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 UploadFile([FromQuery] bool cli = false) { try diff --git a/VoidCat/Services/Users/UserManager.cs b/VoidCat/Services/Users/UserManager.cs index 56621a4..a278be7 100644 --- a/VoidCat/Services/Users/UserManager.cs +++ b/VoidCat/Services/Users/UserManager.cs @@ -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 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) diff --git a/VoidCat/VoidStartup.cs b/VoidCat/VoidStartup.cs index 343f2b3..4c6579e 100644 --- a/VoidCat/VoidStartup.cs +++ b/VoidCat/VoidStartup.cs @@ -140,7 +140,7 @@ public static class VoidStartup services.AddAuthentication(o => { o.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; - o.AddScheme(NostrAuth.Scheme, "Nostr"); + o.AddScheme(NostrAuth.Scheme, null); }) .AddJwtBearer(options => { diff --git a/VoidCat/spa/src/api/package.json b/VoidCat/spa/src/api/package.json index e7e2340..92b7e8d 100644 --- a/VoidCat/spa/src/api/package.json +++ b/VoidCat/spa/src/api/package.json @@ -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", diff --git a/VoidCat/spa/src/api/src/api.ts b/VoidCat/spa/src/api/src/api.ts index e07a48b..a3e99cd 100644 --- a/VoidCat/spa/src/api/src/api.ts +++ b/VoidCat/spa/src/api/src/api.ts @@ -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; + 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(method: string, url: string, body?: object): Promise { + 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(method: string, url: string, body?: object): Promise { - 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("GET", "/info") - } + /** + * General site information + */ + info() { + return this.#req("GET", "/info"); + } - fileInfo(id: string) { - return this.#req("GET", `/upload/${id}`); - } + fileInfo(id: string) { + return this.#req("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("GET", `/upload/${id}/payment`); - } + createOrder(id: string) { + return this.#req("GET", `/upload/${id}/payment`); + } - getOrder(file: string, order: string) { - return this.#req("GET", `/upload/${file}/payment/${order}`); - } + getOrder(file: string, order: string) { + return this.#req("GET", `/upload/${file}/payment/${order}`); + } - login(username: string, password: string, captcha?: string) { - return this.#req("POST", `/auth/login`, { username, password, captcha }); - } + login(username: string, password: string, captcha?: string) { + return this.#req("POST", `/auth/login`, { + username, + password, + captcha, + }); + } - register(username: string, password: string, captcha?: string) { - return this.#req("POST", `/auth/register`, { username, password, captcha }); - } + register(username: string, password: string, captcha?: string) { + return this.#req("POST", `/auth/register`, { + username, + password, + captcha, + }); + } - getUser(id: string) { - return this.#req("GET", `/user/${id}`); - } + getUser(id: string) { + return this.#req("GET", `/user/${id}`); + } - updateUser(u: Profile) { - return this.#req("POST", `/user/${u.id}`, u); - } + updateUser(u: Profile) { + return this.#req("POST", `/user/${u.id}`, u); + } - listUserFiles(uid: string, pageReq: PagedRequest) { - return this.#req>("POST", `/user/${uid}/files`, pageReq); - } + listUserFiles(uid: string, pageReq: PagedRequest) { + return this.#req>( + "POST", + `/user/${uid}/files`, + pageReq + ); + } - submitVerifyCode(uid: string, code: string) { - return this.#req("POST", `/user/${uid}/verify`, { code }); - } + submitVerifyCode(uid: string, code: string) { + return this.#req("POST", `/user/${uid}/verify`, { code }); + } - sendNewCode(uid: string) { - return this.#req("GET", `/user/${uid}/verify`); - } + sendNewCode(uid: string) { + return this.#req("GET", `/user/${uid}/verify`); + } - updateFileMetadata(id: string, meta: any) { - return this.#req("POST", `/upload/${id}/meta`, meta); - } + updateFileMetadata(id: string, meta: any) { + return this.#req("POST", `/upload/${id}/meta`, meta); + } - listApiKeys() { - return this.#req>("GET", `/auth/api-key`); - } + listApiKeys() { + return this.#req>("GET", `/auth/api-key`); + } - createApiKey(req: any) { - return this.#req("POST", `/auth/api-key`, req); - } + createApiKey(req: any) { + return this.#req("POST", `/auth/api-key`, req); + } - adminListFiles(pageReq: PagedRequest) { - return this.#req>("POST", "/admin/file", pageReq); - } + adminListFiles(pageReq: PagedRequest) { + return this.#req>( + "POST", + "/admin/file", + pageReq + ); + } - adminDeleteFile(id: string) { - return this.#req("DELETE", `/admin/file/${id}`); - } + adminDeleteFile(id: string) { + return this.#req("DELETE", `/admin/file/${id}`); + } - adminUserList(pageReq: PagedRequest) { - return this.#req>("POST", `/admin/users`, pageReq); - } + adminUserList(pageReq: PagedRequest) { + return this.#req>( + "POST", + `/admin/users`, + pageReq + ); + } - adminUpdateUser(u: AdminProfile) { - return this.#req("POST", `/admin/update-user`, u); - } -} \ No newline at end of file + adminUpdateUser(u: AdminProfile) { + return this.#req("POST", `/admin/update-user`, u); + } +} diff --git a/VoidCat/spa/src/api/src/stream-uploader.ts b/VoidCat/spa/src/api/src/stream-uploader.ts index 5f73f1f..fec3651 100644 --- a/VoidCat/spa/src/api/src/stream-uploader.ts +++ b/VoidCat/spa/src/api/src/stream-uploader.ts @@ -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, diff --git a/VoidCat/spa/src/api/src/upload.ts b/VoidCat/spa/src/api/src/upload.ts index bd6e735..156bd18 100644 --- a/VoidCat/spa/src/api/src/upload.ts +++ b/VoidCat/spa/src/api/src/upload.ts @@ -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; - // 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; + /** + * 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; -} \ No newline at end of file + /** + * Get the encryption key, should be called after enableEncryption() + */ + abstract getEncryptionKey(): string | undefined; +} diff --git a/VoidCat/spa/src/api/src/xhr-uploader.ts b/VoidCat/spa/src/api/src/xhr-uploader.ts index fdfbb28..fee8e26 100644 --- a/VoidCat/spa/src/api/src/xhr-uploader.ts +++ b/VoidCat/spa/src/api/src/xhr-uploader.ts @@ -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 { - 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 { + 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((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((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); - } - }); - } -} \ No newline at end of file + 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); + } + }); + } +}