Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions bridge/server/money.lua
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,19 @@ function money.add(source, moneyType, amount, reason)
local p = player_mod.get(source)
if not p then
dprint('add: player not found for src=%s — aborting', tostring(source))
return
return false
end

if framework.name == 'qb' then
dprint('add: calling QBCore AddMoney type=%s amount=%s', tostring(converted), tostring(amount))
p.Functions.AddMoney(converted, amount, reason)
return p.Functions.AddMoney(converted, amount, reason) ~= false
elseif framework.name == 'esx' then
dprint('add: calling ESX addAccountMoney type=%s amount=%s', tostring(converted), tostring(amount))
p.addAccountMoney(converted, amount)
return true
else
dprint('add: unsupported framework %s — no money added', tostring(framework.name))
return false
end
end

Expand Down
7 changes: 6 additions & 1 deletion server/computers/appdata.lua
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,12 @@ end
---@return string|nil why locale key
function appdata.set(app, owner, key, value)
local lock = app .. '|' .. owner
while writing[lock] do Wait(0) end
local waited = 0
while writing[lock] do
if waited >= 500 then return false, 'err.internal' end
waited = waited + 1
Wait(10)
end
writing[lock] = true
local ok, done, why = pcall(set, app, owner, key, value)
writing[lock] = nil
Expand Down
15 changes: 9 additions & 6 deletions server/computers/boss.lua
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ rpc.register('boss:setGrade', 'boss', function(src, s, payload)
local target = employees.sourceOf(cid)
if target then notify.to(target, { title = def.label, description = locale('boss.notify_grade', def.grades[grade].name), type = 'inform' }) end
return rpc.ok()
end)
end, 1)

---Fire someone below your grade (online or offline).
---@param payload { cid: string }
Expand All @@ -151,7 +151,7 @@ rpc.register('boss:fire', 'boss', function(src, s, payload)
audit(s, logs.EVENT.BOSS_FIRE, locale('logs.boss_fire', s.name, name, def and def.label or s.bossJob), { cid = cid })
if target then notify.to(target, { title = def and def.label or s.bossJob, description = locale('boss.notify_fired'), type = 'error' }) end
return rpc.ok()
end)
end, 1)

---Players near the COMPUTER who could be hired (not yourself, not already in this job).
rpc.register('boss:nearby', 'boss', function(src, s)
Expand Down Expand Up @@ -213,7 +213,7 @@ rpc.register('boss:hire', 'boss', function(src, s, payload)
audit(s, logs.EVENT.BOSS_HIRE, locale('logs.boss_hire', s.name, name, def.label), { cid = player.getIdentifier(target), byId = payload.byId == true or nil })
notify.to(target, { title = def.label, description = locale('boss.notify_hired', def.label), type = 'success' })
return rpc.ok({ name = name })
end)
end, 1)

---Move the boss's own money into the society. Debit first (it's the step that can fail on
---insufficient funds); if the society credit then fails, the debit is refunded.
Expand All @@ -236,7 +236,7 @@ rpc.register('boss:deposit', 'boss', function(src, s, payload)
end
audit(s, logs.EVENT.BOSS_DEPOSIT, locale('logs.boss_deposit', s.name, amount, label), { amount = amount, from = from })
return rpc.ok({ balance = society.balance(s.bossJob) })
end)
end, 1)

---Move society money to the boss. The society debit is refused outright when it can't cover it.
---@param payload { amount: number }
Expand All @@ -248,9 +248,12 @@ rpc.register('boss:withdraw', 'boss', function(src, s, payload)
local def = employees.job(s.bossJob)
local label = def and def.label or s.bossJob
if not society.withdraw(s.bossJob, amount, s.name, label) then return rpc.fail('boss.err_society_broke') end
money.add(src, B.WithdrawTo or 'bank', amount, 'sd-computers-society-withdraw')
if not money.add(src, B.WithdrawTo or 'bank', amount, 'sd-computers-society-withdraw') then
society.deposit(s.bossJob, amount, s.name, label)
return rpc.fail('boss.err_failed', '?')
end
audit(s, logs.EVENT.BOSS_WITHDRAW, locale('logs.boss_withdraw', s.name, amount, label), { amount = amount })
return rpc.ok({ balance = society.balance(s.bossJob) })
end)
end, 1)

return {}
17 changes: 16 additions & 1 deletion server/computers/mirror.lua
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ local MAX_BATCH = 1024 * 1024
local RATE = 256 * 1024
---@type integer Bytes: the most a session can have saved up.
local BURST = 2 * 1024 * 1024
local OUT_RATE = 1024 * 1024
local OUT_BURST = 12 * 1024 * 1024
---@type integer Bytes: batches up to this go as ordinary events, larger ones as latent events.
local SMALL = 4 * 1024
---@type integer Bytes a second a latent batch is sent to a watcher at.
Expand All @@ -62,6 +64,7 @@ local watchers = {}
local watching = {}
---@type table<integer, { tokens: number, at: integer }> session holder -> their byte budget.
local budget = {}
local relayed = {}
---@type integer Counts sessions, so a client can tell a new one from the one it was watching.
local serial = 0

Expand Down Expand Up @@ -145,6 +148,17 @@ local function spend(src, size)
return true
end

local function spendOut(src, size)
local now = GetGameTimer()
local b = relayed[src]
if not b then b = { tokens = OUT_BURST, at = now }; relayed[src] = b end
b.tokens = math.min(OUT_BURST, b.tokens + (now - b.at) / 1000 * OUT_RATE)
b.at = now
if b.tokens < size then return false end
b.tokens = b.tokens - size
return true
end

session.onOpen(function(src, s)
if not ENABLED then return end
serial = serial + 1
Expand All @@ -157,6 +171,7 @@ session.onClose(function(src, s)
if not a or a.src ~= src then return end
active[s.instance] = nil
budget[src] = nil
relayed[src] = nil
for w in pairs(watchers[s.instance] or {}) do
if watching[w] then watching[w][s.instance] = nil end
end
Expand Down Expand Up @@ -211,7 +226,7 @@ RegisterNetEvent('sd-computers:server:mirror:batch', function(seq, full, blob)
local n = type(seq) == 'number' and math.tointeger(seq) or nil
if not n or type(blob) ~= 'string' then return end
local size = #blob
if size == 0 or size > MAX_BATCH or not spend(src, size) then return end
if size == 0 or size > MAX_BATCH or not spend(src, size) or not spendOut(src, size * count(list)) then return end

full = full == true
for w in pairs(list) do
Expand Down
16 changes: 16 additions & 0 deletions server/computers/ratelimit.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ function ratelimit.take(src, key, seconds)
return true
end

local buckets = {}

function ratelimit.bucket(src, key, burst, perSecond)
local now = GetGameTimer()
local mine = buckets[src]
if not mine then mine = {}; buckets[src] = mine end
local b = mine[key]
if not b then b = { tokens = burst, at = now }; mine[key] = b end
b.tokens = math.min(burst, b.tokens + (now - b.at) / 1000 * perSecond)
b.at = now
if b.tokens < 1 then return false end
b.tokens = b.tokens - 1
return true
end

---Seconds left on `src`'s cooldown for `key` (0 when free).
---@param src integer player server id
---@param key string action key
Expand All @@ -33,6 +48,7 @@ end
---@param src integer player server id
function ratelimit.clear(src)
until_[src] = nil
buckets[src] = nil
end

return ratelimit
13 changes: 11 additions & 2 deletions server/computers/rpc.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ local log = require 'shared.log'
---@type table Session gate (server.computers.session).
local session = require 'server.computers.session'

local ratelimit = require 'server.computers.ratelimit'

---@type table RPC module; the table returned at end of file.
local rpc = {}

Expand All @@ -15,8 +17,8 @@ local handlers = {}
---@param name string endpoint, e.g. 'mail:send'
---@param app string|nil app the caller must have here (nil = any signed-in session)
---@param fn fun(src: integer, s: ComputerSession, payload: table): table handler returning { ok, data?, error? }
function rpc.register(name, app, fn)
handlers[name] = { app = app, fn = fn }
function rpc.register(name, app, fn, limit)
handlers[name] = { app = app, fn = fn, limit = limit }
end

---Shorthand success reply.
Expand All @@ -43,8 +45,11 @@ lib.callback.register('sd-computers:server:rpc', function(src, name, payload)
local h = type(name) == 'string' and handlers[name]
if not h then return rpc.fail('err.invalid') end

if not ratelimit.bucket(src, 'rpc', 40, 15) then return rpc.fail('err.slow_down') end

local s, err = session.guard(src, h.app)
if not s then return { ok = false, error = err } end
if h.limit and not ratelimit.take(src, 'rpc:' .. name, h.limit) then return rpc.fail('err.slow_down') end

local ok, res = pcall(h.fn, src, s, type(payload) == 'table' and payload or {})
if not ok then
Expand All @@ -54,4 +59,8 @@ lib.callback.register('sd-computers:server:rpc', function(src, name, payload)
return res or rpc.ok()
end)

AddEventHandler('playerDropped', function()
ratelimit.clear(source)
end)

return rpc
21 changes: 20 additions & 1 deletion server/computers/session.lua
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,14 @@ function session.actor(s)
return { cid = s.citizenid, name = s.name }
end

local function sameText(a, b)
local diff = #a ~ #b
for i = 1, math.max(#a, #b) do
diff = diff | ((a:byte(i) or 0) ~ (b:byte(i) or 0))
end
return diff == 0
end

---Seconds left on a player's PIN lockout for a computer (0 = not locked out).
---@param citizenid string
---@param id string computer id
Expand Down Expand Up @@ -409,9 +417,10 @@ function session.signIn(src, pin)
local left = lockoutLeft(s.citizenid, s.def.id)
if left > 0 then return { ok = false, error = locale('err.locked_out', left), lockedFor = left } end

if tostring(pin or '') ~= tostring(expected) then
if not sameText(tostring(pin or ''), tostring(expected)) then
local st = pinState[key] or { fails = 0 }
st.fails = st.fails + 1
st.at = os.time()
pinState[key] = st
logs.write(s.instance, {
level = 'failure', source = 'Security', eventId = logs.EVENT.LOGON_FAILED,
Expand Down Expand Up @@ -649,4 +658,14 @@ AddEventHandler('playerDropped', function()
session.close(source)
end)

CreateThread(function()
while true do
Wait(10 * 60 * 1000)
local now = os.time()
for key, st in pairs(pinState) do
if now - math.max(st.lockedUntil or 0, st.at or 0) > 600 then pinState[key] = nil end
end
end
end)

return session
7 changes: 4 additions & 3 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import type { HackRun, OpenPayload } from './types';
import type { AppId, HackRun, OpenPayload } from './types';
import { useNuiEvent } from './hooks/useNuiEvent';
import { fetchNui, isEnvBrowser } from './nui/bridge';
import { mockPayloadFor } from './nui/mock/payload';
Expand All @@ -16,6 +16,7 @@ import Computer from './os/Computer';
import LibHack from './os/LibHack';
import Paper, { type PaperPage } from './os/Paper';
import { mockPage } from './nui/mock/print';
import type { FontKey } from './lib/richdoc';
import { useExtEvents } from './apps/ext/events';

/** computers:mail:received (built-in Mail) - also what a phone mail push is reshaped into. */
Expand Down Expand Up @@ -161,7 +162,7 @@ export default function App() {
// (on a prop's screen the prop is the monitor: retro leaves its own chassis off, as Lua asks it to)
if (query.has('frame')) mockPayload.desktop.monitor = false;
if (hash === 'paper') {
setPaper(mockPage(query.get('kind'), query.get('font') as never));
setPaper(mockPage(query.get('kind'), query.get('font') as FontKey | null));
return;
}
if (hash === 'lib') {
Expand All @@ -172,7 +173,7 @@ export default function App() {
if (hash) {
const os = useOS.getState();
os.setPhase('desktop');
if (hash !== 'desktop') window.setTimeout(() => os.openApp(hash as never), 50);
if (hash !== 'desktop') window.setTimeout(() => os.openApp(hash as AppId), 50);
}
// Reopen after "shutting down" so the browser session never ends up on a blank page.
const reopen = () => window.setTimeout(() => open(mockPayload), 1200);
Expand Down