1
0
mirror of https://github.com/citizenfx/cfx-server-data.git synced 2025-01-06 21:02:57 +08:00

runcode: improved UI, in-game support, JS support

This commit is contained in:
blattersturm 2019-10-21 15:04:55 +02:00
parent 64a26ad1de
commit 54f75bca86
10 changed files with 702 additions and 57 deletions

1
resources/runcode/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
data.json

View File

@ -2,7 +2,16 @@ client_script 'runcode_cl.lua'
server_script 'runcode_sv.lua'
server_script 'runcode_web.lua'
client_script 'runcode_shared.lua'
server_script 'runcode_shared.lua'
shared_script 'runcode_shared.lua'
resource_manifest_version '44febabe-d386-4d18-afbe-5e627f4af937'
shared_script 'runcode.js'
resource_manifest_version '44febabe-d386-4d18-afbe-5e627f4af937'
client_script 'runcode_ui.lua'
ui_page 'web/nui.html'
files {
'web/nui.html'
}

View File

@ -0,0 +1,11 @@
exports('runJS', (snippet) => {
if (IsDuplicityVersion() && GetInvokingResource() !== GetCurrentResourceName()) {
return [ 'Invalid caller.', false ];
}
try {
return [ new Function(snippet)(), false ];
} catch (e) {
return [ false, e.toString() ];
}
});

View File

@ -1,7 +1,7 @@
RegisterNetEvent('runcode:gotSnippet')
AddEventHandler('runcode:gotSnippet', function(id, code)
local res, err = RunCode(code)
AddEventHandler('runcode:gotSnippet', function(id, lang, code)
local res, err = RunCode(lang, code)
if not err then
if type(res) == 'vector3' then

View File

@ -1,5 +1,12 @@
function RunCode(code)
local code, err = load(code, '@runcode')
local runners = {}
function runners.lua(arg)
local code, err = load('return ' .. arg, '@runcode')
-- if failed, try without return
if err then
code, err = load(arg, '@runcode')
end
if err then
print(err)
@ -11,7 +18,15 @@ function RunCode(code)
if status then
return result
else
return nil, result
end
return nil, result
end
function runners.js(arg)
return table.unpack(exports[GetCurrentResourceName()]:runJS(arg))
end
function RunCode(lang, str)
return runners[lang](str)
end

View File

@ -1,7 +1,42 @@
function GetPrivs(source)
return {
canServer = IsPlayerAceAllowed(source, 'command.run'),
canClient = IsPlayerAceAllowed(source, 'command.crun'),
canSelf = IsPlayerAceAllowed(source, 'runcode.self'),
}
end
RegisterCommand('run', function(source, args, rawCommand)
local res, err = RunCode('return ' .. rawCommand:sub(4))
local res, err = RunCode('lua', rawCommand:sub(4))
end, true)
RegisterCommand('crun', function(source, args, rawCommand)
TriggerClientEvent('runcode:gotSnippet', source, -1, 'return ' .. rawCommand:sub(5))
if not source then
return
end
TriggerClientEvent('runcode:gotSnippet', source, -1, 'lua', rawCommand:sub(5))
end, true)
RegisterCommand('runcode', function(source, args, rawCommand)
if not source then
return
end
local df = LoadResourceFile(GetCurrentResourceName(), 'data.json')
local saveData = {}
if df then
saveData = json.decode(df)
end
local p = GetPrivs(source)
if not p.canServer and not p.canClient and not p.canSelf then
return
end
p.saveData = saveData
TriggerClientEvent('runcode:openUi', source, p)
end, true)

View File

@ -0,0 +1,66 @@
local openData
RegisterNetEvent('runcode:openUi')
AddEventHandler('runcode:openUi', function(options)
openData = {
type = 'open',
options = options,
url = 'http://' .. GetCurrentServerEndpoint() .. '/' .. GetCurrentResourceName() .. '/',
res = GetCurrentResourceName()
}
SendNuiMessage(json.encode(openData))
end)
RegisterNUICallback('getOpenData', function(args, cb)
cb(openData)
end)
RegisterNUICallback('doOk', function(args, cb)
SendNuiMessage(json.encode({
type = 'ok'
}))
SetNuiFocus(true, true)
cb('ok')
end)
RegisterNUICallback('doClose', function(args, cb)
SendNuiMessage(json.encode({
type = 'close'
}))
SetNuiFocus(false, false)
cb('ok')
end)
local rcCbs = {}
local id = 1
RegisterNUICallback('runCodeInBand', function(args, cb)
id = id + 1
rcCbs[id] = cb
TriggerServerEvent('runcode:runInBand', id, args)
end)
RegisterNetEvent('runcode:inBandResult')
AddEventHandler('runcode:inBandResult', function(id, result)
if rcCbs[id] then
local cb = rcCbs[id]
rcCbs[id] = nil
cb(result)
end
end)
AddEventHandler('onResourceStop', function(resourceName)
if resourceName == GetCurrentResourceName() then
SetNuiFocus(false, false)
end
end)

View File

@ -21,6 +21,69 @@ end
local codeId = 1
local codes = {}
local attempts = 0
local lastAttempt
local function handleRunCode(data, res)
if not data.lang then
data.lang = 'lua'
end
if not data.client or data.client == '' then
CreateThread(function()
local result, err = RunCode(data.lang, data.code)
res.send(json.encode({
result = result,
error = err
}))
end)
else
codes[codeId] = {
timeout = GetGameTimer() + 1000,
res = res
}
TriggerClientEvent('runcode:gotSnippet', tonumber(data.client), codeId, data.lang, data.code)
codeId = codeId + 1
end
end
RegisterNetEvent('runcode:runInBand')
AddEventHandler('runcode:runInBand', function(id, data)
local s = source
local privs = GetPrivs(s)
local res = {
send = function(str)
TriggerClientEvent('runcode:inBandResult', s, id, str)
end
}
if (not data.client or data.client == '') and not privs.canServer then
res.send(json.encode({ error = 'Insufficient permissions.'}))
return
end
if (data.client and data.client ~= '') and not privs.canClient then
if privs.canSelf then
data.client = s
else
res.send(json.encode({ error = 'Insufficient permissions.'}))
return
end
end
SaveResourceFile(GetCurrentResourceName(), 'data.json', json.encode({
lastSnippet = data.code,
lastLang = data.lang or 'lua'
}), -1)
handleRunCode(data, res)
end)
local function handlePost(req, res)
req.setDataHandler(function(body)
local data = json.decode(body)
@ -35,33 +98,29 @@ local function handlePost(req, res)
return
end
if data.password ~= GetConvar('rcon_password', '') then
if attempts > 5 or data.password ~= GetConvar('rcon_password', '') then
attempts = attempts + 1
lastAttempt = GetGameTimer()
res.send(json.encode({ error = 'Bad password.'}))
return
end
if not data.client or data.client == '' then
CreateThread(function()
local result, err = RunCode(data.code)
res.send(json.encode({
result = result,
error = err
}))
end)
else
codes[codeId] = {
timeout = GetGameTimer() + 1000,
res = res
}
TriggerClientEvent('runcode:gotSnippet', tonumber(data.client), codeId, data.code)
codeId = codeId + 1
end
handleRunCode(data, res)
end)
end
CreateThread(function()
while true do
Wait(1000)
if attempts > 0 and (GetGameTimer() - lastAttempt) > 5000 then
attempts = 0
lastAttempt = 0
end
end
end)
local function returnCode(id, res, err)
if not codes[id] then
return

View File

@ -6,79 +6,468 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta http-equiv="Content-Type" content="text/html;charset=utf-8">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulmaswatch/0.7.2/cyborg/bulmaswatch.min.css">
<script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
<style type="text/css">
body {
font-family: "Segoe UI", sans-serif;
}
.navbar {
z-index: inherit;
}
html.in-nui {
overflow: hidden;
background: transparent;
margin-top: 5vh;
margin-left: 7.5vw;
margin-right: 7.5vw;
margin-bottom: 5vh;
height: calc(100% - 10vh);
position: relative;
}
html.in-nui body > div.bg {
background-color: rgba(0, 0, 0, 1);
position: absolute;
top: 0px;
bottom: 0px;
left: 0px;
right: 0px;
z-index: -999;
box-shadow: 0 22px 70px 4px rgba(0, 0, 0, 0.56);
}
span.nui-edition {
display: none;
}
html.in-nui span.nui-edition {
display: inline;
}
#close {
display: none;
}
html.in-nui #close {
display: block;
}
#result {
margin-top: 0.5em;
}
.navbar {
border-top: none;
border-left: none;
border-right: none;
}
</style>
</head>
<body>
<div id="code-container" style="width:800px;height:600px;border:1px solid grey"></div><br>
Password: <input type="password" id="password"> (use your rcon password)<br>
Client ID: <input type="text" id="client"> (leave blank for server)<br>
<div id="clients"></div>
<button id="run">Run</button>
<div id="result">
<div class="bg">
</div>
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<a class="navbar-item" href="/runcode">
<strong>runcode</strong> <span class="nui-edition">&nbsp;in-game</span>
</a>
<a role="button" class="navbar-burger burger" aria-label="menu" aria-expanded="false" data-target="navbarMain">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarMain" class="navbar-menu">
<div class="navbar-end">
<div class="navbar-item">
<div class="field" id="cl-field">
<div class="control has-icons-left">
<div class="select">
<select id="cl-select">
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-user"></i>
</div>
</div>
</div>
</div>
<div class="navbar-item">
<div class="field has-addons" id="lang-toggle">
<p class="control">
<button class="button" id="lua-button">
<span class="icon is-small">
<i class="fas fa-moon"></i>
</span>
<span>Lua</span>
</button>
</p>
<p class="control">
<button class="button" id="js-button">
<span class="icon is-small">
<i class="fab fa-js"></i>
</span>
<span>JS</span>
</button>
</p>
<!-- TODO pending add-on resource that'll contain webpack'd compiler
<p class="control">
<button class="button" id="ts-button">
<span class="icon is-small">
<i class="fas fa-code"></i>
</span>
<span>TS</span>
</button>
</p>
-->
</div>
</div>
<div class="navbar-item">
<div class="field has-addons" id="cl-sv-toggle">
<p class="control">
<button class="button" id="cl-button">
<span class="icon is-small">
<i class="fas fa-user-friends"></i>
</span>
<span>Client</span>
</button>
</p>
<p class="control">
<button class="button" id="sv-button">
<span class="icon is-small">
<i class="fas fa-server"></i>
</span>
<span>Server</span>
</button>
</p>
</div>
</div>
<div class="navbar-item" id="close">
<button class="button is-danger">Close</button>
</div>
</div>
</div>
</div>
</nav>
<section class="section">
<div class="container">
<div id="code-container" style="width:100%;height:60vh;border:1px solid grey"></div><br>
<div class="field" id="passwordField">
<p class="control has-icons-left">
<input class="input" type="password" id="password" placeholder="RCon Password">
<span class="icon is-small is-left">
<i class="fas fa-lock"></i>
</span>
</p>
</div>
<button class="button is-primary" id="run">Run</button>
<div id="result">
</div>
</div>
</section>
<!--
to use a local deployment, uncomment; do note currently the server isn't optimized to serve >1MB files
<script src="monaco-editor/vs/loader.js"></script>
-->
<script src="https://unpkg.com/monaco-editor@0.10.0/min/vs/loader.js"></script>
<script src="https://unpkg.com/monaco-editor@0.18.1/min/vs/loader.js"></script>
<script>
function fetchClients() {
fetch('/runcode/clients').then(res => res.json()).then(res => {
const el = document.querySelector('#clients');
el.innerHTML = '';
const el = document.querySelector('#cl-select');
const clients = res.clients;
clients.push(['All', -1]);
const realClients = [['All', '-1'], ...clients];
for (const client of clients) {
const l = document.createElement('a');
l.addEventListener('click', e => {
document.querySelector('#client').value = client[1];
e.preventDefault();
});
const createdClients = new Set([...el.querySelectorAll('option').entries()].map(([i, el]) => el.value));
const existentClients = new Set(realClients.map(([ name, id ]) => id));
l.setAttribute('href', 'javascript:void(0)');
l.appendChild(document.createTextNode(client[0]));
const toRemove = [...createdClients].filter(a => !existentClients.has(a));
el.appendChild(l);
el.appendChild(document.createTextNode(' '));
for (const [name, id] of realClients) {
const ex = el.querySelector(`option[value="${id}"]`);
if (!ex) {
const l = document.createElement('option');
l.setAttribute('value', id);
l.appendChild(document.createTextNode(name));
el.appendChild(l);
}
}
for (const id of toRemove) {
const l = el.querySelector(`option[value="${id}"]`);
if (l) {
el.removeChild(l);
}
}
});
}
let useClient = false;
let editServerCb = null;
[['#cl-button', true], ['#sv-button', false]].forEach(([ selector, isClient ]) => {
const eh = () => {
if (isClient) {
document.querySelector('#cl-select').disabled = false;
useClient = true;
} else {
document.querySelector('#cl-select').disabled = true;
useClient = false;
}
document.querySelectorAll('#cl-sv-toggle button').forEach(el => {
el.classList.remove('is-selected', 'is-info');
});
const tgt = document.querySelector(selector);
tgt.classList.add('is-selected', 'is-info');
if (editServerCb) {
editServerCb();
}
};
// default to not-client
if (!isClient) {
eh();
}
document.querySelector(selector).addEventListener('click', ev => {
eh();
ev.preventDefault();
});
});
let lang = 'lua';
let editLangCb = null;
let initCb = null;
function getLangCode(lang) {
switch (lang) {
case 'js':
return 'javascript';
case 'ts':
return 'typescript';
}
return lang;
}
[['#lua-button', 'lua'], ['#js-button', 'js']/*, ['#ts-button', 'ts']*/].forEach(([ selector, langOpt ]) => {
const eh = () => {
lang = langOpt;
document.querySelectorAll('#lang-toggle button').forEach(el => {
el.classList.remove('is-selected', 'is-info');
});
const tgt = document.querySelector(selector);
tgt.classList.add('is-selected', 'is-info');
if (editLangCb) {
editLangCb();
}
};
// default to not-client
if (langOpt === 'lua') {
eh();
}
document.querySelector(selector).addEventListener('click', ev => {
eh();
ev.preventDefault();
});
});
setInterval(() => fetchClients(), 1000);
require.config({ paths: { 'vs': 'https://unpkg.com/monaco-editor@0.10.0/min/vs' }});
const inNui = (!!window.invokeNative);
let openData = {};
if (inNui) {
document.querySelector('#passwordField').style.display = 'none';
document.querySelector('html').classList.add('in-nui');
fetch(`http://${window.parent.GetParentResourceName()}/getOpenData`, {
method: 'POST',
body: '{}'
}).then(a => a.json())
.then(a => {
openData = a;
if (!openData.options.canServer) {
document.querySelector('#cl-sv-toggle').style.display = 'none';
const trigger = document.createEvent('HTMLEvents');
trigger.initEvent('click', true, true);
document.querySelector('#cl-button').dispatchEvent(trigger);
} else if (!openData.options.canClient && !openData.options.canSelf) {
document.querySelector('#cl-sv-toggle').style.display = 'none';
document.querySelector('#cl-field').style.display = 'none';
const trigger = document.createEvent('HTMLEvents');
trigger.initEvent('click', true, true);
document.querySelector('#sv-button').dispatchEvent(trigger);
}
if (!openData.options.canClient && openData.options.canSelf) {
document.querySelector('#cl-field').style.display = 'none';
}
if (openData.options.saveData) {
const cb = () => {
if (initCb) {
initCb({
lastLang: openData.options.saveData.lastLang,
lastSnippet: openData.options.saveData.lastSnippet
});
} else {
setTimeout(cb, 50);
}
};
setTimeout(cb, 50);
}
fetch(`https://${window.parent.GetParentResourceName()}/doOk`, {
method: 'POST',
body: '{}'
});
});
document.querySelector('#close button').addEventListener('click', ev => {
fetch(`https://${window.parent.GetParentResourceName()}/doClose`, {
method: 'POST',
body: '{}'
});
ev.preventDefault();
});
}
const defFiles = ['index.d.ts'];
const defFilesServer = [...defFiles, 'natives_server.d.ts'];
const defFilesClient = [...defFiles, 'natives_universal.d.ts'];
const prefix = 'https://unpkg.com/@citizenfx/{}/';
const prefixClient = prefix.replace('{}', 'client');
const prefixServer = prefix.replace('{}', 'server');
require.config({ paths: { 'vs': 'https://unpkg.com/monaco-editor@0.18.1/min/vs' }});
require(['vs/editor/editor.main'], function() {
const editor = monaco.editor.create(document.getElementById('code-container'), {
value: 'return 42',
language: 'lua'
});
monaco.editor.setTheme('vs-dark');
let finalizers = [];
const updateScript = (client, lang) => {
finalizers.forEach(a => a());
finalizers = [];
if (lang === 'js' || lang === 'ts') {
const defaults = (lang === 'js') ? monaco.languages.typescript.javascriptDefaults :
monaco.languages.typescript.typescriptDefaults;
defaults.setCompilerOptions({
noLib: true,
allowNonTsExtensions: true
});
for (const file of (client ? defFilesClient : defFilesServer)) {
const prefix = (client ? prefixClient : prefixServer);
fetch(`${prefix}${file}`)
.then(a => a.text())
.then(a => {
const l = defaults.addExtraLib(a, file);
finalizers.push(() => l.dispose());
});
}
}
}
editLangCb = () => {
monaco.editor.setModelLanguage(editor.getModel(), getLangCode(lang));
updateScript(useClient, lang);
};
editServerCb = () => {
updateScript(useClient, lang);
};
initCb = (data) => {
if (data.lastLang) {
const trigger = document.createEvent('HTMLEvents');
trigger.initEvent('click', true, true);
document.querySelector(`#${data.lastLang}-button`).dispatchEvent(trigger);
}
if (data.lastSnippet) {
editor.getModel().setValue(data.lastSnippet);
}
};
document.querySelector('#run').addEventListener('click', e => {
const text = editor.getValue();
fetch('/runcode/', {
fetch((!inNui) ? '/runcode/' : `https://${openData.res}/runCodeInBand`, {
method: 'post',
body: JSON.stringify({
password: document.querySelector('#password').value,
client: document.querySelector('#client').value,
code: text
client: (useClient) ? document.querySelector('#cl-select').value : '',
code: text,
lang: lang
})
}).then(res => res.json()).then(res => {
if (inNui) {
res = JSON.parse(res); // double packing for sad msgpack-to-json
}
const resultElement = document.querySelector('#result');
if (res.error) {
resultElement.style.color = '#aa0000';
resultElement.classList.remove('notification', 'is-success');
resultElement.classList.add('notification', 'is-danger');
} else {
resultElement.style.color = '#000000';
resultElement.classList.remove('notification', 'is-danger');
resultElement.classList.add('notification', 'is-success');
}
resultElement.innerHTML = res.error || res.result;

View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<meta charset="utf-8">
<title>runcode nui</title>
<style type="text/css">
html {
overflow: hidden;
}
body {
background-color: transparent;
margin: 0px;
padding: 0px;
}
iframe {
width: 100%;
height: 100%;
position: absolute;
top: 0px;
bottom: 0px;
left: 0px;
right: 0px;
overflow: hidden;
}
</style>
<div id="holder">
</div>
<script type="text/javascript">
let openData = null;
window.addEventListener('message', ev => {
switch (ev.data.type) {
case 'open':
const frame = document.createElement('iframe');
frame.name = 'rc';
frame.allow = 'microphone *;';
frame.src = ev.data.url;
frame.style.visibility = 'hidden';
openData = ev.data;
openData.frame = frame;
document.querySelector('#holder').appendChild(frame);
break;
case 'ok':
openData.frame.style.visibility = 'visible';
break;
case 'close':
document.querySelector('#holder').removeChild(openData.frame);
openData = null;
break;
}
});
</script>