| 1 | // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt |
| 2 | |
| 3 | import _ from 'lodash' |
| 4 | import { Connection, disconnect, getConnections } from './connections' |
| 5 | import { apiAssertTypes, isLocalHost, safeDecodeURIComponent, shortenAgent, wait, wantArray } from './misc' |
| 6 | import { ApiHandlers } from './apiMiddleware' |
| 7 | import Koa from 'koa' |
| 8 | import { totalGot, totalInSpeedKb, totalOutSpeedKb, totalSent } from './throttler' |
| 9 | import { getCurrentUsername } from './auth' |
| 10 | import { SendListReadable } from './SendList' |
| 11 | import { storedMap } from './persistence' |
| 12 | import { countUniqueBy } from './cross' |
| 13 | |
| 14 | export default { |
| 15 | |
| 16 | async disconnect({ ip, port, allButLocalhost }) { |
| 17 | apiAssertTypes({ |
| 18 | string_undefined: { ip }, |
| 19 | number_undefined: { port }, |
| 20 | boolean_undefined: { allButLocalhost }, |
| 21 | }) |
| 22 | const match = allButLocalhost ? ((x: any) => !isLocalHost(x.ip)) |
| 23 | : _.matches({ ip, port }) |
| 24 | const found = getConnections().filter(c => match(getConnAddress(c))) |
| 25 | for (const c of found) |
| 26 | disconnect(c.socket, "manual disconnection") |
| 27 | return { result: found.length } |
| 28 | }, |
| 29 | |
| 30 | get_connections({}, ctx) { |
| 31 | const list = new SendListReadable({ |
| 32 | diff: true, |
| 33 | addAtStart: getConnections().map(serializeConnection), |
| 34 | }) |
| 35 | type Change = Partial<Omit<Connection,'ip'>> |
| 36 | list.props({ you: ctx.ip }) |
| 37 | return list.events(ctx, { |
| 38 | connection(conn: Connection) { |
| 39 | list.add(serializeConnection(conn)) |
| 40 | }, |
| 41 | connectionClosed(conn: Connection) { |
| 42 | list.remove(getConnAddress(conn)) |
| 43 | }, |
| 44 | connectionNewIp(conn: Connection, oldIp: string, newIp: string) { |
| 45 | list.update(getConnAddress(conn, oldIp), { ip: newIp }) |
| 46 | }, |
| 47 | connectionUpdated(conn: Connection, change: Change) { |
| 48 | if (conn.socket.closed || _.isEmpty(change)) return |
| 49 | if (change.ctx) { |
| 50 | Object.assign(change, fromCtx(change.ctx)) |
| 51 | change.ctx = undefined |
| 52 | } |
| 53 | list.update(getConnAddress(conn), change) |
| 54 | }, |
| 55 | }) |
| 56 | }, |
| 57 | |
| 58 | async *get_connection_stats() { |
| 59 | while (1) { |
| 60 | const connections = getConnections() |
| 61 | yield { |
| 62 | outSpeedKb: totalOutSpeedKb, |
| 63 | inSpeedKb: totalInSpeedKb, |
| 64 | sent_got: [totalSent.get(), totalGot.get(), totalGotSentResetTime.get()] as const, |
| 65 | connections: connections.length, |
| 66 | ips: countUniqueBy(connections, conn => conn.ip), |
| 67 | } |
| 68 | await wait(1000) |
| 69 | } |
| 70 | }, |
| 71 | |
| 72 | async clear_persistent({ k }) { |
| 73 | apiAssertTypes({ string_array: { k } }) |
| 74 | totalGotSentResetTime.set(new Date) |
| 75 | for (const x of wantArray(k)) |
| 76 | void storedMap.del(x) |
| 77 | }, |
| 78 | |
| 79 | } satisfies ApiHandlers |
| 80 | |
| 81 | export function serializeConnection(conn: Connection) { |
| 82 | const { socket, started, secure } = conn |
| 83 | return { |
| 84 | ...getConnAddress(conn), |
| 85 | v: (socket.remoteFamily?.endsWith('6') ? 6 : 4), |
| 86 | // connection fields are request-scoped once transfer tracking starts; socket counters cover earlier snapshots |
| 87 | got: conn.got || socket.bytesRead, |
| 88 | sent: conn.sent || socket.bytesWritten, |
| 89 | outSpeedKb: conn.outSpeedKb, |
| 90 | inSpeedKb: conn.inSpeedKb, |
| 91 | country: conn.country, |
| 92 | started, |
| 93 | secure: (secure || undefined) as boolean|undefined, // undefined will save some space once json-ed |
| 94 | ...fromCtx(conn.ctx), |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | function fromCtx(ctx?: Koa.Context) { |
| 99 | if (!ctx) return |
| 100 | return { |
| 101 | user: getCurrentUsername(ctx), |
| 102 | agent: shortenAgent(ctx.get('user-agent')), |
| 103 | ...inferOperation(ctx) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | export function inferOperation(ctx: Koa.Context) { |
| 108 | const s = ctx.state // short alias |
| 109 | return { |
| 110 | archive: s.archive, |
| 111 | ...s.browsing ? { op: 'browsing', path: safeDecodeURIComponent(s.browsing) } |
| 112 | : s.uploadPath ? { op: 'upload', path: safeDecodeURIComponent(s.uploadPath) } |
| 113 | : { |
| 114 | op: !s.considerAsGui && (ctx.state.archive || ctx.state.vfsNode) ? 'download' : undefined, |
| 115 | path: safeDecodeURIComponent(ctx.originalUrl), |
| 116 | }, |
| 117 | opProgress: _.isNumber(s.opProgress) ? _.round(s.opProgress, 3) : undefined, |
| 118 | opTotal: s.opTotal, |
| 119 | opOffset: s.opOffset, |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | function getConnAddress(conn: Connection, overrideIp?: string) { |
| 124 | return { |
| 125 | ip: overrideIp ?? conn.ip, |
| 126 | port: conn.socket.remotePort, |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | const totalGotSentResetTime = storedMap.singleSync('totalGotSentResetTime', new Date(0)) |
| 131 | totalGotSentResetTime.ready().then(() => // because default value is not stored, and we need to init this value |
| 132 | totalGotSentResetTime.set(was => was.getTime() ? was : new Date) ) |