mirror of
https://github.com/mollersuite/monofile.git
synced 2024-11-21 13:36:25 -08:00
more unifying work
This commit is contained in:
parent
9fba6b15e8
commit
c214a06c67
|
@ -4,8 +4,8 @@ import Files from "./lib/files.js"
|
|||
import { program } from "commander"
|
||||
import { basename } from "path"
|
||||
import { Writable } from "node:stream"
|
||||
import config from "./lib/config.js"
|
||||
import pkg from "../../package.json" assert { type: "json" }
|
||||
import config from "../../config.json" assert { type: "json" }
|
||||
import { fileURLToPath } from "url"
|
||||
import { dirname } from "path"
|
||||
|
||||
|
@ -23,65 +23,61 @@ program
|
|||
.description("Quickly run monofile to execute a query or so")
|
||||
.version(pkg.version)
|
||||
|
||||
program.command("list")
|
||||
program
|
||||
.command("list")
|
||||
.alias("ls")
|
||||
.description("List files in the database")
|
||||
.action(() => {
|
||||
Object.keys(files.files).forEach(e => console.log(e))
|
||||
Object.keys(files.files).forEach((e) => console.log(e))
|
||||
})
|
||||
|
||||
|
||||
program.command("download")
|
||||
program
|
||||
.command("download")
|
||||
.alias("dl")
|
||||
.description("Download a file from the database")
|
||||
.argument("<id>", "ID of the file you'd like to download")
|
||||
.option("-o, --output <path>", 'Folder or filename to output to')
|
||||
.option("-o, --output <path>", "Folder or filename to output to")
|
||||
.action(async (id, options) => {
|
||||
|
||||
await (new Promise<void>(resolve => setTimeout(() => resolve(), 1000)))
|
||||
await new Promise<void>((resolve) => setTimeout(() => resolve(), 1000))
|
||||
|
||||
let fp = files.files[id]
|
||||
|
||||
if (!fp)
|
||||
throw `file ${id} not found`
|
||||
|
||||
let out = options.output as string || `./`
|
||||
if (!fp) throw `file ${id} not found`
|
||||
|
||||
let out = (options.output as string) || `./`
|
||||
|
||||
if (fs.existsSync(out) && (await stat(out)).isDirectory())
|
||||
out = `${out.replace(/\/+$/, "")}/${fp.filename}`
|
||||
|
||||
let filestream = await files.readFileStream(id)
|
||||
|
||||
let prog=0
|
||||
filestream.on("data", dt => {
|
||||
prog+=dt.byteLength
|
||||
console.log(`Downloading ${fp.filename}: ${Math.floor(prog/(fp.sizeInBytes??0)*10000)/100}% (${Math.floor(prog/(1024*1024))}MiB/${Math.floor((fp.sizeInBytes??0)/(1024*1024))}MiB)`)
|
||||
let prog = 0
|
||||
filestream.on("data", (dt) => {
|
||||
prog += dt.byteLength
|
||||
console.log(
|
||||
`Downloading ${fp.filename}: ${Math.floor((prog / (fp.sizeInBytes ?? 0)) * 10000) / 100}% (${Math.floor(prog / (1024 * 1024))}MiB/${Math.floor((fp.sizeInBytes ?? 0) / (1024 * 1024))}MiB)`
|
||||
)
|
||||
})
|
||||
|
||||
filestream.pipe(
|
||||
fs.createWriteStream(out)
|
||||
)
|
||||
filestream.pipe(fs.createWriteStream(out))
|
||||
})
|
||||
|
||||
|
||||
program.command("upload")
|
||||
program
|
||||
.command("upload")
|
||||
.alias("up")
|
||||
.description("Upload a file to the instance")
|
||||
.argument("<file>", "Path to the file you'd like to upload")
|
||||
.option("-id, --fileid <id>", 'Custom file ID to use')
|
||||
.option("-id, --fileid <id>", "Custom file ID to use")
|
||||
.action(async (file, options) => {
|
||||
|
||||
await (new Promise<void>(resolve => setTimeout(() => resolve(), 1000)))
|
||||
await new Promise<void>((resolve) => setTimeout(() => resolve(), 1000))
|
||||
|
||||
if (!(fs.existsSync(file) && (await stat(file)).isFile()))
|
||||
throw `${file} is not a file`
|
||||
|
||||
|
||||
let writable = files.createWriteStream()
|
||||
|
||||
writable
|
||||
.setName(basename(file))
|
||||
?.setType("application/octet-stream")
|
||||
|
||||
writable.setName(basename(file))?.setType("application/octet-stream")
|
||||
|
||||
if (options.id) writable.setUploadId(options.id)
|
||||
|
||||
if (!(writable instanceof Writable))
|
||||
|
@ -90,7 +86,7 @@ program.command("upload")
|
|||
console.log(`started: ${file}`)
|
||||
|
||||
writable.on("drain", () => {
|
||||
console.log("Drained");
|
||||
console.log("Drained")
|
||||
})
|
||||
|
||||
writable.on("finish", async () => {
|
||||
|
@ -108,11 +104,9 @@ program.command("upload")
|
|||
|
||||
writable.on("close", () => {
|
||||
console.log("Closed.")
|
||||
});
|
||||
})
|
||||
|
||||
;(await fs.createReadStream(file)).pipe(
|
||||
writable
|
||||
)
|
||||
;(await fs.createReadStream(file)).pipe(writable)
|
||||
})
|
||||
|
||||
program.parse()
|
||||
program.parse()
|
||||
|
|
|
@ -10,7 +10,7 @@ import preview from "./routes/api/web/preview.js"
|
|||
import { fileURLToPath } from "url"
|
||||
import { dirname } from "path"
|
||||
import pkg from "../../package.json" assert { type: "json" }
|
||||
import config from "../../config.json" assert { type: "json" }
|
||||
import config, { ClientConfiguration } from "./lib/config.js"
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
|
@ -67,7 +67,7 @@ app.get("/server", (ctx) =>
|
|||
maxDiscordFiles: config.maxDiscordFiles,
|
||||
maxDiscordFileSize: config.maxDiscordFileSize,
|
||||
accounts: config.accounts,
|
||||
})
|
||||
} as ClientConfiguration)
|
||||
)
|
||||
|
||||
// funcs
|
||||
|
|
|
@ -2,12 +2,15 @@ import { REST } from "./DiscordRequests.js"
|
|||
import type { APIMessage } from "discord-api-types/v10"
|
||||
import FormData from "form-data"
|
||||
import { Transform, type Readable } from "node:stream"
|
||||
import { Configuration } from "../files.js"
|
||||
import type { Configuration } from "../config.js"
|
||||
|
||||
const EXPIRE_AFTER = 20 * 60 * 1000
|
||||
const DISCORD_EPOCH = 1420070400000
|
||||
// Converts a snowflake ID string into a JS Date object using the provided epoch (in ms), or Discord's epoch if not provided
|
||||
function convertSnowflakeToDate(snowflake: string|number, epoch = DISCORD_EPOCH) {
|
||||
function convertSnowflakeToDate(
|
||||
snowflake: string | number,
|
||||
epoch = DISCORD_EPOCH
|
||||
) {
|
||||
// Convert snowflake to BigInt to extract timestamp bits
|
||||
// https://discord.com/developers/docs/reference#snowflakes
|
||||
const milliseconds = BigInt(snowflake) >> 22n
|
||||
|
@ -15,133 +18,164 @@ function convertSnowflakeToDate(snowflake: string|number, epoch = DISCORD_EPOCH)
|
|||
}
|
||||
|
||||
interface MessageCacheObject {
|
||||
expire: number,
|
||||
object: string
|
||||
expire: number
|
||||
object: string
|
||||
}
|
||||
|
||||
export class Client {
|
||||
private readonly token : string
|
||||
private readonly rest : REST
|
||||
private readonly targetChannel : string
|
||||
private readonly config : Configuration
|
||||
private messageCache : Map<string, MessageCacheObject> = new Map()
|
||||
private readonly token: string
|
||||
private readonly rest: REST
|
||||
private readonly targetChannel: string
|
||||
private readonly config: Configuration
|
||||
private messageCache: Map<string, MessageCacheObject> = new Map()
|
||||
|
||||
constructor(token: string, config: Configuration) {
|
||||
this.token = token
|
||||
this.rest = new REST(token)
|
||||
this.targetChannel = config.targetChannel
|
||||
this.config = config
|
||||
}
|
||||
constructor(token: string, config: Configuration) {
|
||||
this.token = token
|
||||
this.rest = new REST(token)
|
||||
this.targetChannel = config.targetChannel
|
||||
this.config = config
|
||||
}
|
||||
|
||||
async fetchMessage(id: string, cache: boolean = true) {
|
||||
if (cache && this.messageCache.has(id)) {
|
||||
let cachedMessage = this.messageCache.get(id)!
|
||||
if (cachedMessage.expire >= Date.now()) {
|
||||
return JSON.parse(cachedMessage.object) as APIMessage
|
||||
}
|
||||
}
|
||||
async fetchMessage(id: string, cache: boolean = true) {
|
||||
if (cache && this.messageCache.has(id)) {
|
||||
let cachedMessage = this.messageCache.get(id)!
|
||||
if (cachedMessage.expire >= Date.now()) {
|
||||
return JSON.parse(cachedMessage.object) as APIMessage
|
||||
}
|
||||
}
|
||||
|
||||
let message = await (this.rest.fetch(`/channels/${this.targetChannel}/messages/${id}`).then(res=>res.json()) as Promise<APIMessage>)
|
||||
let message = await (this.rest
|
||||
.fetch(`/channels/${this.targetChannel}/messages/${id}`)
|
||||
.then((res) => res.json()) as Promise<APIMessage>)
|
||||
|
||||
this.messageCache.set(id, { object: JSON.stringify(message) /* clone object so that removing ids from the array doesn't. yeah */, expire: EXPIRE_AFTER + Date.now() })
|
||||
return message
|
||||
}
|
||||
this.messageCache.set(id, {
|
||||
object: JSON.stringify(
|
||||
message
|
||||
) /* clone object so that removing ids from the array doesn't. yeah */,
|
||||
expire: EXPIRE_AFTER + Date.now(),
|
||||
})
|
||||
return message
|
||||
}
|
||||
|
||||
async deleteMessage(id: string) {
|
||||
await this.rest.fetch(`/channels/${this.targetChannel}/messages/${id}`, {method: "DELETE"})
|
||||
this.messageCache.delete(id)
|
||||
}
|
||||
async deleteMessage(id: string) {
|
||||
await this.rest.fetch(
|
||||
`/channels/${this.targetChannel}/messages/${id}`,
|
||||
{ method: "DELETE" }
|
||||
)
|
||||
this.messageCache.delete(id)
|
||||
}
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#bulk-delete-messages
|
||||
// https://discord.com/developers/docs/resources/channel#bulk-delete-messages
|
||||
// "This endpoint will not delete messages older than 2 weeks" so we need to check each id
|
||||
async deleteMessages(ids: string[]) {
|
||||
|
||||
// Remove bulk deletable messages
|
||||
// Remove bulk deletable messages
|
||||
|
||||
let bulkDeletable = ids.filter(e => Date.now()-convertSnowflakeToDate(e).valueOf() < 2 * 7 * 24 * 60 * 60 * 1000)
|
||||
await this.rest.fetch(`/channels/${this.targetChannel}/messages/bulk-delete`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({messages: bulkDeletable})
|
||||
})
|
||||
let bulkDeletable = ids.filter(
|
||||
(e) =>
|
||||
Date.now() - convertSnowflakeToDate(e).valueOf() <
|
||||
2 * 7 * 24 * 60 * 60 * 1000
|
||||
)
|
||||
await this.rest.fetch(
|
||||
`/channels/${this.targetChannel}/messages/bulk-delete`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ messages: bulkDeletable }),
|
||||
}
|
||||
)
|
||||
bulkDeletable.forEach(Map.prototype.delete.bind(this.messageCache))
|
||||
|
||||
// everything else, we can do manually...
|
||||
// there's probably a better way to do this @Jack5079
|
||||
// fix for me if possible
|
||||
await Promise.all(ids.map(async e => {
|
||||
if (Date.now()-convertSnowflakeToDate(e).valueOf() >= 2 * 7 * 24 * 60 * 60 * 1000) {
|
||||
return await this.deleteMessage(e)
|
||||
}
|
||||
}).filter(Boolean)) // filter based on whether or not it's undefined
|
||||
|
||||
// everything else, we can do manually...
|
||||
// there's probably a better way to do this @Jack5079
|
||||
// fix for me if possible
|
||||
await Promise.all(
|
||||
ids
|
||||
.map(async (e) => {
|
||||
if (
|
||||
Date.now() - convertSnowflakeToDate(e).valueOf() >=
|
||||
2 * 7 * 24 * 60 * 60 * 1000
|
||||
) {
|
||||
return await this.deleteMessage(e)
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
) // filter based on whether or not it's undefined
|
||||
}
|
||||
|
||||
async send(stream: Readable) {
|
||||
|
||||
let bytes_sent = 0
|
||||
let file_number = 0
|
||||
let boundary = "-".repeat(20) + Math.random().toString().slice(2)
|
||||
|
||||
let pushBoundary = (stream: Readable) =>
|
||||
stream.push(`${(file_number++) == 0 ? "" : "\r\n"}--${boundary}\r\nContent-Disposition: form-data; name="files[${file_number}]"; filename="${Math.random().toString().slice(2)}\r\nContent-Type: application/octet-stream\r\n\r\n`)
|
||||
let boundPush = (stream: Readable, chunk: Buffer) => {
|
||||
let position = 0
|
||||
console.log(`Chunk length ${chunk.byteLength}`)
|
||||
async send(stream: Readable) {
|
||||
let bytes_sent = 0
|
||||
let file_number = 0
|
||||
let boundary = "-".repeat(20) + Math.random().toString().slice(2)
|
||||
|
||||
while (position < chunk.byteLength) {
|
||||
if ((bytes_sent % this.config.maxDiscordFileSize) == 0) {
|
||||
console.log("Progress is 0. Pushing boundary")
|
||||
pushBoundary(stream)
|
||||
}
|
||||
let pushBoundary = (stream: Readable) =>
|
||||
stream.push(
|
||||
`${file_number++ == 0 ? "" : "\r\n"}--${boundary}\r\nContent-Disposition: form-data; name="files[${file_number}]"; filename="${Math.random().toString().slice(2)}\r\nContent-Type: application/octet-stream\r\n\r\n`
|
||||
)
|
||||
let boundPush = (stream: Readable, chunk: Buffer) => {
|
||||
let position = 0
|
||||
console.log(`Chunk length ${chunk.byteLength}`)
|
||||
|
||||
let capture = Math.min(
|
||||
(this.config.maxDiscordFileSize - (bytes_sent % this.config.maxDiscordFileSize)),
|
||||
chunk.byteLength-position
|
||||
)
|
||||
console.log(`Capturing ${capture} bytes, ${chunk.subarray(position, position+capture).byteLength}`)
|
||||
stream.push( chunk.subarray(position, position + capture) )
|
||||
position += capture, bytes_sent += capture
|
||||
while (position < chunk.byteLength) {
|
||||
if (bytes_sent % this.config.maxDiscordFileSize == 0) {
|
||||
console.log("Progress is 0. Pushing boundary")
|
||||
pushBoundary(stream)
|
||||
}
|
||||
|
||||
console.log("Chunk progress:", bytes_sent % this.config.maxDiscordFileSize, "B")
|
||||
}
|
||||
let capture = Math.min(
|
||||
this.config.maxDiscordFileSize -
|
||||
(bytes_sent % this.config.maxDiscordFileSize),
|
||||
chunk.byteLength - position
|
||||
)
|
||||
console.log(
|
||||
`Capturing ${capture} bytes, ${chunk.subarray(position, position + capture).byteLength}`
|
||||
)
|
||||
stream.push(chunk.subarray(position, position + capture))
|
||||
;(position += capture), (bytes_sent += capture)
|
||||
|
||||
|
||||
}
|
||||
console.log(
|
||||
"Chunk progress:",
|
||||
bytes_sent % this.config.maxDiscordFileSize,
|
||||
"B"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let transformed = new Transform({
|
||||
transform(chunk, encoding, callback) {
|
||||
boundPush(this, chunk)
|
||||
callback()
|
||||
},
|
||||
flush(callback) {
|
||||
this.push(`\r\n--${boundary}--`)
|
||||
callback()
|
||||
}
|
||||
})
|
||||
let transformed = new Transform({
|
||||
transform(chunk, encoding, callback) {
|
||||
boundPush(this, chunk)
|
||||
callback()
|
||||
},
|
||||
flush(callback) {
|
||||
this.push(`\r\n--${boundary}--`)
|
||||
callback()
|
||||
},
|
||||
})
|
||||
|
||||
let controller = new AbortController()
|
||||
stream.on("error", _ => controller.abort())
|
||||
let controller = new AbortController()
|
||||
stream.on("error", (_) => controller.abort())
|
||||
|
||||
//pushBoundary(transformed)
|
||||
stream.pipe(transformed)
|
||||
//pushBoundary(transformed)
|
||||
stream.pipe(transformed)
|
||||
|
||||
let returned = await this.rest.fetch(`/channels/${this.targetChannel}/messages`, {
|
||||
method: "POST",
|
||||
body: transformed,
|
||||
headers: {
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`
|
||||
},
|
||||
signal: controller.signal
|
||||
})
|
||||
let returned = await this.rest.fetch(
|
||||
`/channels/${this.targetChannel}/messages`,
|
||||
{
|
||||
method: "POST",
|
||||
body: transformed,
|
||||
headers: {
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
}
|
||||
)
|
||||
|
||||
if (!returned.ok) {
|
||||
throw new Error(
|
||||
`[Message creation] ${returned.status} ${returned.statusText}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!returned.ok) {
|
||||
throw new Error(`[Message creation] ${returned.status} ${returned.statusText}`)
|
||||
}
|
||||
|
||||
let response = (await returned.json() as APIMessage)
|
||||
console.log(JSON.stringify(response, null, 4))
|
||||
return response
|
||||
|
||||
}
|
||||
}
|
||||
let response = (await returned.json()) as APIMessage
|
||||
console.log(JSON.stringify(response, null, 4))
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,45 @@
|
|||
import "dotenv/config"
|
||||
|
||||
export interface Configuration {
|
||||
port: number
|
||||
requestTimeout: number
|
||||
trustProxy: boolean
|
||||
forceSSL: boolean
|
||||
discordToken: string
|
||||
maxDiscordFiles: number
|
||||
maxDiscordFileSize: number
|
||||
maxUploadIdLength: number
|
||||
targetGuild: string
|
||||
targetChannel: string
|
||||
accounts: {
|
||||
registrationEnabled: boolean
|
||||
requiredForUpload: boolean
|
||||
}
|
||||
mail: {
|
||||
transport: {
|
||||
host: string
|
||||
port: number
|
||||
secure: boolean
|
||||
}
|
||||
send: {
|
||||
from: string
|
||||
}
|
||||
user: string
|
||||
pass: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClientConfiguration {
|
||||
version: string
|
||||
files: number
|
||||
maxDiscordFiles: number
|
||||
maxDiscordFileSize: number
|
||||
accounts: {
|
||||
registrationEnabled: boolean
|
||||
requiredForUpload: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
port: Number(process.env.PORT),
|
||||
requestTimeout: Number(process.env.REQUEST_TIMEOUT),
|
||||
|
@ -29,4 +69,4 @@ export default {
|
|||
user: process.env.MAIL__USER,
|
||||
pass: process.env.MAIL__PASS,
|
||||
},
|
||||
}
|
||||
} as Configuration
|
||||
|
|
|
@ -4,7 +4,7 @@ import crypto from "node:crypto"
|
|||
import { files } from "./accounts.js"
|
||||
import { Client as API } from "./DiscordAPI/index.js"
|
||||
import type { APIAttachment } from "discord-api-types/v10"
|
||||
import config from "./config.js"
|
||||
import config, { Configuration } from "./config.js"
|
||||
import "dotenv/config"
|
||||
|
||||
import * as Accounts from "./accounts.js"
|
||||
|
@ -47,22 +47,6 @@ function multiAssert(
|
|||
export type FileUploadSettings = Partial<Pick<FilePointer, "mime" | "owner">> &
|
||||
Pick<FilePointer, "mime" | "filename"> & { uploadId?: string }
|
||||
|
||||
export interface Configuration {
|
||||
maxDiscordFiles: number
|
||||
maxDiscordFileSize: number
|
||||
targetChannel: string
|
||||
requestTimeout: number
|
||||
maxUploadIdLength: number
|
||||
|
||||
accounts: {
|
||||
registrationEnabled: boolean
|
||||
requiredForUpload: boolean
|
||||
}
|
||||
|
||||
trustProxy: boolean
|
||||
forceSSL: boolean
|
||||
}
|
||||
|
||||
export interface FilePointer {
|
||||
filename: string
|
||||
mime: string
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
requiresPermissions,
|
||||
} from "../../../lib/middleware.js"
|
||||
import { accountRatelimit } from "../../../lib/ratelimit.js"
|
||||
|
||||
import config from "../../../lib/config.js"
|
||||
import ServeError from "../../../lib/errors.js"
|
||||
import Files, {
|
||||
FileVisibility,
|
||||
|
@ -26,7 +26,6 @@ export let authRoutes = new Hono<{
|
|||
}
|
||||
}>()
|
||||
|
||||
import config from "../../../../../config.json" assert {type:"json"}
|
||||
authRoutes.all("*", getAccount)
|
||||
|
||||
export default function (files: Files) {
|
||||
|
@ -419,10 +418,13 @@ export default function (files: Files) {
|
|||
|
||||
pwReset.set(acc.id, {
|
||||
code,
|
||||
expiry: setTimeout(() => {
|
||||
pwReset.delete(acc?.id || "")
|
||||
prcIdx.delete(pResetCode?.code || "")
|
||||
}, 15 * 60 * 1000),
|
||||
expiry: setTimeout(
|
||||
() => {
|
||||
pwReset.delete(acc?.id || "")
|
||||
prcIdx.delete(pResetCode?.code || "")
|
||||
},
|
||||
15 * 60 * 1000
|
||||
),
|
||||
requestedAt: Date.now(),
|
||||
})
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
// Modules
|
||||
|
||||
|
||||
import { type Context, Hono } from "hono"
|
||||
import { getCookie, setCookie } from "hono/cookie"
|
||||
|
||||
|
@ -20,54 +19,83 @@ import {
|
|||
import ServeError from "../../../lib/errors.js"
|
||||
import { CodeMgr, sendMail } from "../../../lib/mail.js"
|
||||
|
||||
import Configuration from "../../../../../config.json" assert {type:"json"}
|
||||
import Configuration from "../../../lib/config.js"
|
||||
|
||||
const router = new Hono<{
|
||||
Variables: {
|
||||
account: Accounts.Account,
|
||||
account: Accounts.Account
|
||||
target: Accounts.Account
|
||||
}
|
||||
}>()
|
||||
|
||||
type UserUpdateParameters = Partial<Omit<Accounts.Account, "password"> & { password: string, currentPassword?: string }>
|
||||
type UserUpdateParameters = Partial<
|
||||
Omit<Accounts.Account, "password"> & {
|
||||
password: string
|
||||
currentPassword?: string
|
||||
}
|
||||
>
|
||||
type Message = [200 | 400 | 401 | 403 | 429 | 501, string]
|
||||
|
||||
// there's probably a less stupid way to do this than `K in keyof Pick<UserUpdateParameters, T>`
|
||||
// @Jack5079 make typings better if possible
|
||||
|
||||
type Validator<T extends keyof Partial<Accounts.Account>, ValueNotNull extends boolean> =
|
||||
type Validator<
|
||||
T extends keyof Partial<Accounts.Account>,
|
||||
ValueNotNull extends boolean,
|
||||
> =
|
||||
/**
|
||||
* @param actor The account performing this action
|
||||
* @param target The target account for this action
|
||||
* @param params Changes being patched in by the user
|
||||
*/
|
||||
(actor: Accounts.Account, target: Accounts.Account, params: UserUpdateParameters & (ValueNotNull extends true ? {
|
||||
[K in keyof Pick<UserUpdateParameters, T>]-? : UserUpdateParameters[K]
|
||||
} : {}), ctx: Context) => Accounts.Account[T] | Message
|
||||
(
|
||||
actor: Accounts.Account,
|
||||
target: Accounts.Account,
|
||||
params: UserUpdateParameters &
|
||||
(ValueNotNull extends true
|
||||
? {
|
||||
[K in keyof Pick<
|
||||
UserUpdateParameters,
|
||||
T
|
||||
>]-?: UserUpdateParameters[K]
|
||||
}
|
||||
: {}),
|
||||
ctx: Context
|
||||
) => Accounts.Account[T] | Message
|
||||
|
||||
// this type is so stupid stg
|
||||
type ValidatorWithSettings<T extends keyof Partial<Accounts.Account>> = {
|
||||
acceptsNull: true,
|
||||
validator: Validator<T, false>
|
||||
} | {
|
||||
acceptsNull?: false,
|
||||
validator: Validator<T, true>
|
||||
}
|
||||
type ValidatorWithSettings<T extends keyof Partial<Accounts.Account>> =
|
||||
| {
|
||||
acceptsNull: true
|
||||
validator: Validator<T, false>
|
||||
}
|
||||
| {
|
||||
acceptsNull?: false
|
||||
validator: Validator<T, true>
|
||||
}
|
||||
|
||||
const validators: {
|
||||
[T in keyof Partial<Accounts.Account>]:
|
||||
Validator<T, true> | ValidatorWithSettings<T>
|
||||
[T in keyof Partial<Accounts.Account>]:
|
||||
| Validator<T, true>
|
||||
| ValidatorWithSettings<T>
|
||||
} = {
|
||||
defaultFileVisibility(actor, target, params) {
|
||||
if (["public", "private", "anonymous"].includes(params.defaultFileVisibility))
|
||||
if (
|
||||
["public", "private", "anonymous"].includes(
|
||||
params.defaultFileVisibility
|
||||
)
|
||||
)
|
||||
return params.defaultFileVisibility
|
||||
else return [400, "invalid file visibility"]
|
||||
},
|
||||
email: {
|
||||
acceptsNull: true,
|
||||
validator: (actor, target, params, ctx) => {
|
||||
if (!params.currentPassword // actor on purpose here to allow admins
|
||||
|| (params.currentPassword && Accounts.password.check(actor.id, params.currentPassword)))
|
||||
if (
|
||||
!params.currentPassword || // actor on purpose here to allow admins
|
||||
(params.currentPassword &&
|
||||
Accounts.password.check(actor.id, params.currentPassword))
|
||||
)
|
||||
return [401, "current password incorrect"]
|
||||
|
||||
if (!params.email) {
|
||||
|
@ -81,13 +109,17 @@ const validators: {
|
|||
return undefined
|
||||
}
|
||||
|
||||
if (typeof params.email !== "string") return [400, "email must be string"]
|
||||
if (actor.admin)
|
||||
return params.email
|
||||
if (typeof params.email !== "string")
|
||||
return [400, "email must be string"]
|
||||
if (actor.admin) return params.email
|
||||
|
||||
// send verification email
|
||||
|
||||
if ((CodeMgr.codes.verifyEmail.byUser.get(target.id)?.length || 0) >= 2) return [429, "you have too many active codes"]
|
||||
if (
|
||||
(CodeMgr.codes.verifyEmail.byUser.get(target.id)?.length ||
|
||||
0) >= 2
|
||||
)
|
||||
return [429, "you have too many active codes"]
|
||||
|
||||
let code = new CodeMgr.Code("verifyEmail", target.id, params.email)
|
||||
|
||||
|
@ -108,81 +140,97 @@ const validators: {
|
|||
)
|
||||
|
||||
return [200, "please check your inbox"]
|
||||
}
|
||||
},
|
||||
},
|
||||
password(actor, target, params) {
|
||||
if (
|
||||
!params.currentPassword // actor on purpose here to allow admins
|
||||
|| (params.currentPassword && Accounts.password.check(actor.id, params.currentPassword))
|
||||
) return [401, "current password incorrect"]
|
||||
!params.currentPassword || // actor on purpose here to allow admins
|
||||
(params.currentPassword &&
|
||||
Accounts.password.check(actor.id, params.currentPassword))
|
||||
)
|
||||
return [401, "current password incorrect"]
|
||||
|
||||
if (
|
||||
typeof params.password != "string"
|
||||
|| params.password.length < 8
|
||||
) return [400, "password must be 8 characters or longer"]
|
||||
if (typeof params.password != "string" || params.password.length < 8)
|
||||
return [400, "password must be 8 characters or longer"]
|
||||
|
||||
if (target.email) {
|
||||
sendMail(
|
||||
target.email,
|
||||
`Your login details have been updated`,
|
||||
`<b>Hello there!</b> Your password on your account, <span username>${target.username}</span>, has been updated`
|
||||
+ `${actor != target ? ` by <span username>${actor.username}</span>` : ""}. `
|
||||
+ `Please update your saved login details accordingly.`
|
||||
`<b>Hello there!</b> Your password on your account, <span username>${target.username}</span>, has been updated` +
|
||||
`${actor != target ? ` by <span username>${actor.username}</span>` : ""}. ` +
|
||||
`Please update your saved login details accordingly.`
|
||||
).catch()
|
||||
}
|
||||
|
||||
return Accounts.password.hash(params.password)
|
||||
|
||||
},
|
||||
username(actor, target, params) {
|
||||
if (!params.currentPassword // actor on purpose here to allow admins
|
||||
|| (params.currentPassword && Accounts.password.check(actor.id, params.currentPassword)))
|
||||
if (
|
||||
!params.currentPassword || // actor on purpose here to allow admins
|
||||
(params.currentPassword &&
|
||||
Accounts.password.check(actor.id, params.currentPassword))
|
||||
)
|
||||
return [401, "current password incorrect"]
|
||||
|
||||
if (
|
||||
typeof params.username != "string"
|
||||
|| params.username.length < 3
|
||||
|| params.username.length > 20
|
||||
) return [400, "username must be between 3 and 20 characters in length"]
|
||||
typeof params.username != "string" ||
|
||||
params.username.length < 3 ||
|
||||
params.username.length > 20
|
||||
)
|
||||
return [
|
||||
400,
|
||||
"username must be between 3 and 20 characters in length",
|
||||
]
|
||||
|
||||
if (Accounts.getFromUsername(params.username))
|
||||
return [400, "account with this username already exists"]
|
||||
|
||||
if ((params.username.match(/[A-Za-z0-9_\-\.]+/) || [])[0] != params.username)
|
||||
if (
|
||||
(params.username.match(/[A-Za-z0-9_\-\.]+/) || [])[0] !=
|
||||
params.username
|
||||
)
|
||||
return [400, "username has invalid characters"]
|
||||
|
||||
if (target.email) {
|
||||
sendMail(
|
||||
target.email,
|
||||
`Your login details have been updated`,
|
||||
`<b>Hello there!</b> Your username on your account, <span username>${target.username}</span>, has been updated`
|
||||
+ `${actor != target ? ` by <span username>${actor.username}</span>` : ""} to <span username>${params.username}</span>. `
|
||||
+ `Please update your saved login details accordingly.`
|
||||
`<b>Hello there!</b> Your username on your account, <span username>${target.username}</span>, has been updated` +
|
||||
`${actor != target ? ` by <span username>${actor.username}</span>` : ""} to <span username>${params.username}</span>. ` +
|
||||
`Please update your saved login details accordingly.`
|
||||
).catch()
|
||||
}
|
||||
|
||||
return params.username
|
||||
|
||||
},
|
||||
customCSS: {
|
||||
acceptsNull: true,
|
||||
validator: (actor, target, params) => {
|
||||
if (
|
||||
!params.customCSS ||
|
||||
(params.customCSS.match(id_check_regex)?.[0] == params.customCSS &&
|
||||
(params.customCSS.match(id_check_regex)?.[0] ==
|
||||
params.customCSS &&
|
||||
params.customCSS.length <= Configuration.maxUploadIdLength)
|
||||
) return params.customCSS
|
||||
)
|
||||
return params.customCSS
|
||||
else return [400, "bad file id"]
|
||||
}
|
||||
},
|
||||
},
|
||||
embed(actor, target, params) {
|
||||
if (typeof params.embed !== "object") return [400, "must use an object for embed"]
|
||||
if (typeof params.embed !== "object")
|
||||
return [400, "must use an object for embed"]
|
||||
if (params.embed.color === undefined) {
|
||||
params.embed.color = target.embed?.color
|
||||
} else if (!((params.embed.color.toLowerCase().match(/[a-f0-9]+/)?.[0] ==
|
||||
params.embed.color.toLowerCase() &&
|
||||
params.embed.color.length == 6) || params.embed.color == null)) return [400, "bad embed color"]
|
||||
|
||||
} else if (
|
||||
!(
|
||||
(params.embed.color.toLowerCase().match(/[a-f0-9]+/)?.[0] ==
|
||||
params.embed.color.toLowerCase() &&
|
||||
params.embed.color.length == 6) ||
|
||||
params.embed.color == null
|
||||
)
|
||||
)
|
||||
return [400, "bad embed color"]
|
||||
|
||||
if (params.embed.largeImage === undefined) {
|
||||
params.embed.largeImage = target.embed?.largeImage
|
||||
|
@ -194,23 +242,19 @@ const validators: {
|
|||
if (actor.admin && !target.admin) return params.admin
|
||||
else if (!actor.admin) return [400, "cannot promote yourself"]
|
||||
else return [400, "cannot demote an admin"]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
router.use(getAccount)
|
||||
router.all("/:user", async (ctx, next) => {
|
||||
let acc =
|
||||
ctx.req.param("user") == "me"
|
||||
? ctx.get("account")
|
||||
: (
|
||||
ctx.req.param("user").startsWith("@")
|
||||
? Accounts.getFromUsername(ctx.req.param("user").slice(1))
|
||||
: Accounts.getFromId(ctx.req.param("user"))
|
||||
)
|
||||
if (
|
||||
acc != ctx.get("account")
|
||||
&& !ctx.get("account")?.admin
|
||||
) return ServeError(ctx, 403, "you cannot manage this user")
|
||||
let acc =
|
||||
ctx.req.param("user") == "me"
|
||||
? ctx.get("account")
|
||||
: ctx.req.param("user").startsWith("@")
|
||||
? Accounts.getFromUsername(ctx.req.param("user").slice(1))
|
||||
: Accounts.getFromId(ctx.req.param("user"))
|
||||
if (acc != ctx.get("account") && !ctx.get("account")?.admin)
|
||||
return ServeError(ctx, 403, "you cannot manage this user")
|
||||
if (!acc) return ServeError(ctx, 404, "account does not exist")
|
||||
|
||||
ctx.set("target", acc)
|
||||
|
@ -219,14 +263,15 @@ router.all("/:user", async (ctx, next) => {
|
|||
})
|
||||
|
||||
function isMessage(object: any): object is Message {
|
||||
return Array.isArray(object)
|
||||
&& object.length == 2
|
||||
&& typeof object[0] == "number"
|
||||
&& typeof object[1] == "string"
|
||||
return (
|
||||
Array.isArray(object) &&
|
||||
object.length == 2 &&
|
||||
typeof object[0] == "number" &&
|
||||
typeof object[1] == "string"
|
||||
)
|
||||
}
|
||||
|
||||
export default function (files: Files) {
|
||||
|
||||
router.post("/", async (ctx) => {
|
||||
const body = await ctx.req.json()
|
||||
if (!Configuration.accounts.registrationEnabled) {
|
||||
|
@ -282,39 +327,60 @@ export default function (files: Files) {
|
|||
requiresAccount,
|
||||
requiresPermissions("manage"),
|
||||
async (ctx) => {
|
||||
const body = await ctx.req.json() as UserUpdateParameters
|
||||
const body = (await ctx.req.json()) as UserUpdateParameters
|
||||
const actor = ctx.get("account")!
|
||||
const target = ctx.get("target")!
|
||||
if (Array.isArray(body))
|
||||
return ServeError(ctx, 400, "invalid body")
|
||||
if (Array.isArray(body)) return ServeError(ctx, 400, "invalid body")
|
||||
|
||||
let results: ([keyof Accounts.Account, Accounts.Account[keyof Accounts.Account]]|Message)[] =
|
||||
(Object.entries(body)
|
||||
.filter(e => e[0] !== "currentPassword") as [keyof Accounts.Account, UserUpdateParameters[keyof Accounts.Account]][])
|
||||
.map(([x, v]) => {
|
||||
if (!validators[x])
|
||||
return [400, `the ${x} parameter cannot be set or is not a valid parameter`] as Message
|
||||
let results: (
|
||||
| [
|
||||
keyof Accounts.Account,
|
||||
Accounts.Account[keyof Accounts.Account],
|
||||
]
|
||||
| Message
|
||||
)[] = (
|
||||
Object.entries(body).filter(
|
||||
(e) => e[0] !== "currentPassword"
|
||||
) as [
|
||||
keyof Accounts.Account,
|
||||
UserUpdateParameters[keyof Accounts.Account],
|
||||
][]
|
||||
).map(([x, v]) => {
|
||||
if (!validators[x])
|
||||
return [
|
||||
400,
|
||||
`the ${x} parameter cannot be set or is not a valid parameter`,
|
||||
] as Message
|
||||
|
||||
let validator =
|
||||
(typeof validators[x] == "object"
|
||||
? validators[x]
|
||||
: {
|
||||
validator: validators[x] as Validator<typeof x, false>,
|
||||
acceptsNull: false
|
||||
}) as ValidatorWithSettings<typeof x>
|
||||
let validator = (
|
||||
typeof validators[x] == "object"
|
||||
? validators[x]
|
||||
: {
|
||||
validator: validators[x] as Validator<
|
||||
typeof x,
|
||||
false
|
||||
>,
|
||||
acceptsNull: false,
|
||||
}
|
||||
) as ValidatorWithSettings<typeof x>
|
||||
|
||||
if (!validator.acceptsNull && !v)
|
||||
return [400, `the ${x} validator does not accept null values`] as Message
|
||||
if (!validator.acceptsNull && !v)
|
||||
return [
|
||||
400,
|
||||
`the ${x} validator does not accept null values`,
|
||||
] as Message
|
||||
|
||||
return [
|
||||
x,
|
||||
validator.validator(actor, target, body as any, ctx)
|
||||
] as [keyof Accounts.Account, Accounts.Account[keyof Accounts.Account]]
|
||||
})
|
||||
return [
|
||||
x,
|
||||
validator.validator(actor, target, body as any, ctx),
|
||||
] as [
|
||||
keyof Accounts.Account,
|
||||
Accounts.Account[keyof Accounts.Account],
|
||||
]
|
||||
})
|
||||
|
||||
let allMsgs = results.map((v) => {
|
||||
if (isMessage(v))
|
||||
return v
|
||||
if (isMessage(v)) return v
|
||||
target[v[0]] = v[1] as never // lol
|
||||
return [200, "OK"] as Message
|
||||
})
|
||||
|
@ -322,7 +388,9 @@ export default function (files: Files) {
|
|||
await Accounts.save()
|
||||
|
||||
if (allMsgs.length == 1)
|
||||
return ctx.text(...allMsgs[0]!.reverse() as [Message[1], Message[0]]) // im sorry
|
||||
return ctx.text(
|
||||
...(allMsgs[0]!.reverse() as [Message[1], Message[0]])
|
||||
) // im sorry
|
||||
else return ctx.json(allMsgs)
|
||||
}
|
||||
)
|
||||
|
@ -330,11 +398,9 @@ export default function (files: Files) {
|
|||
router.delete("/:user", requiresAccount, noAPIAccess, async (ctx) => {
|
||||
let acc = ctx.get("target")
|
||||
|
||||
auth.AuthTokens.filter((e) => e.account == acc?.id).forEach(
|
||||
(token) => {
|
||||
auth.invalidate(token.token)
|
||||
}
|
||||
)
|
||||
auth.AuthTokens.filter((e) => e.account == acc?.id).forEach((token) => {
|
||||
auth.invalidate(token.token)
|
||||
})
|
||||
|
||||
await Accounts.deleteAccount(acc.id)
|
||||
|
||||
|
@ -342,20 +408,18 @@ export default function (files: Files) {
|
|||
await sendMail(
|
||||
acc.email,
|
||||
"Notice of account deletion",
|
||||
`Your account, <span username>${
|
||||
acc.username
|
||||
}</span>, has been removed. Thank you for using monofile.`
|
||||
`Your account, <span username>${acc.username}</span>, has been removed. Thank you for using monofile.`
|
||||
).catch()
|
||||
return ctx.text("OK")
|
||||
}
|
||||
|
||||
|
||||
return ctx.text("account deleted")
|
||||
})
|
||||
|
||||
router.get("/:user", requiresAccount, async (ctx) => {
|
||||
let acc = ctx.get("target")
|
||||
let sessionToken = auth.tokenFor(ctx)!
|
||||
|
||||
|
||||
return ctx.json({
|
||||
...acc,
|
||||
password: undefined,
|
||||
|
@ -364,19 +428,18 @@ export default function (files: Files) {
|
|||
auth.getPermissions(sessionToken)?.includes("email")
|
||||
? acc.email
|
||||
: undefined,
|
||||
activeSessions: auth.AuthTokens.filter(
|
||||
(e) =>
|
||||
e.type != "App" &&
|
||||
e.account == acc.id &&
|
||||
(e.expire > Date.now() || !e.expire)
|
||||
).length,
|
||||
activeSessions: auth.AuthTokens.filter(
|
||||
(e) =>
|
||||
e.type != "App" &&
|
||||
e.account == acc.id &&
|
||||
(e.expire > Date.now() || !e.expire)
|
||||
).length,
|
||||
})
|
||||
})
|
||||
|
||||
router.get("/css", async (ctx) => {
|
||||
let acc = ctx.get('account')
|
||||
if (acc?.customCSS)
|
||||
return ctx.redirect(`/file/${acc.customCSS}`)
|
||||
let acc = ctx.get("account")
|
||||
if (acc?.customCSS) return ctx.redirect(`/file/${acc.customCSS}`)
|
||||
else return ctx.text("")
|
||||
})
|
||||
|
||||
|
|
|
@ -2,39 +2,53 @@ import { writable } from "svelte/store"
|
|||
//import type Pulldown from "./pulldowns/Pulldown.svelte"
|
||||
import type { SvelteComponent } from "svelte"
|
||||
import type { Account } from "../../server/lib/accounts"
|
||||
import type cfg from "../../../config.json"
|
||||
import type { ClientConfiguration } from "../../server/lib/config"
|
||||
import type { FilePointer } from "../../server/lib/files"
|
||||
|
||||
export let refreshNeeded = writable(false)
|
||||
export let pulldownManager = writable<SvelteComponent>()
|
||||
export let account = writable<Account & {sessionCount: number, sessionExpires: number}|undefined>()
|
||||
export let serverStats = writable<typeof cfg & {version: string, files: number} | undefined>()
|
||||
export let files = writable<(FilePointer & {id:string})[]>([])
|
||||
export let account = writable<
|
||||
(Account & { sessionCount: number; sessionExpires: number }) | undefined
|
||||
>()
|
||||
export let serverStats = writable<ClientConfiguration | undefined>()
|
||||
export let files = writable<(FilePointer & { id: string })[]>([])
|
||||
|
||||
export let fetchAccountData = function() {
|
||||
fetch("/auth/me").then(async (response) => {
|
||||
if (response.status == 200) {
|
||||
account.set(await response.json())
|
||||
} else {
|
||||
account.set(undefined)
|
||||
}
|
||||
}).catch((err) => { console.error(err) })
|
||||
export let fetchAccountData = function () {
|
||||
fetch("/auth/me")
|
||||
.then(async (response) => {
|
||||
if (response.status == 200) {
|
||||
account.set(await response.json())
|
||||
} else {
|
||||
account.set(undefined)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
export let fetchFilePointers = function() {
|
||||
fetch("/files/list", { cache: "no-cache" }).then(async (response) => {
|
||||
if (response.status == 200) {
|
||||
files.set(await response.json())
|
||||
} else {
|
||||
files.set([])
|
||||
}
|
||||
}).catch((err) => { console.error(err) })
|
||||
export let fetchFilePointers = function () {
|
||||
fetch("/files/list", { cache: "no-cache" })
|
||||
.then(async (response) => {
|
||||
if (response.status == 200) {
|
||||
files.set(await response.json())
|
||||
} else {
|
||||
files.set([])
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
export let refresh_stats = () => {
|
||||
fetch("/server").then(async (data) => {
|
||||
serverStats.set(await data.json())
|
||||
}).catch((err) => { console.error(err) })
|
||||
fetch("/server")
|
||||
.then(async (data) => {
|
||||
serverStats.set(await data.json())
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
fetchAccountData()
|
||||
fetchAccountData()
|
||||
|
|
Loading…
Reference in a new issue