feat(dashboard): UGC server page

Counts, a paged list and failures of what the UGC server made of players'
models and modular builds, making one, the failed ones or everything again
(new ugc_manage permission), the UGC server's live status and icons from
ugc_public_url, and a 3D view of a model's LXFML. The UGC settings are in the
settings catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 03:55:30 -05:00
parent abf1cc1d80
commit 347a0a7286
11 changed files with 317 additions and 7 deletions

View File

@@ -90,6 +90,7 @@ namespace {
{ "contraband_manage", "Economy and map", "Contraband list", "Add, change and remove contraband items (flagged, or removed from players, when a character has one)", 8 },
{ "play_keys_manage", "Server", "Play keys", "Create, edit and delete play keys, and see the key an account used", 8 },
{ "ugc_manage", "Server", "UGC processing", "Have the UGC server make player models' meshes and icons again", 8 },
{ "client_files", "Server", "Client files", "Browse and download the game client's files", 8 },
{ "vanity_manage", "Server", "Vanity NPCs", "Edit the vanity files, NPCs and plaque texts, add vanity changes to scheduled events, and respawn them in game", 8 },
{ "tasks_view", "Server", "View scheduled tasks", "See scheduled tasks, their runs and logs", 8 },

View File

@@ -73,6 +73,7 @@
#include "PublicRoutes.h"
#include "Showcase.h"
#include "ContrabandRoutes.h"
#include "UgcRoutes.h"
#include "PropertyRentRoutes.h"
#include "FeaturedProperties.h"
#include "PasswordRecovery.h"
@@ -527,6 +528,7 @@ int main(int argc, char** argv) {
RegisterShowcaseRoutes();
FeaturedProperties::RegisterRoutes();
ContrabandRoutes::RegisterRoutes();
UgcRoutes::RegisterRoutes();
PropertyRentRoutes::RegisterRoutes();
RegisterPasswordRecoveryRoutes();
RegisterWSRoutes();

View File

@@ -52,6 +52,7 @@ set(DASHBOARDROUTES_SOURCES
"Showcase.cpp"
"FeaturedProperties.cpp"
"ContrabandRoutes.cpp"
"UgcRoutes.cpp"
"PropertyRentRoutes.cpp"
"PasswordRecovery.cpp"
"PrometheusMetrics.cpp"

View File

@@ -295,6 +295,7 @@ namespace {
c.Add(Port(DASHBOARD, "port", "Web port", "Where the dashboard listens for browsers.", "2006"));
c.Add(Format(Text(DASHBOARD, "listen_ip", "Listen address", "127.0.0.1 for this machine only (put a reverse proxy in front); 0.0.0.0 for everyone.", "127.0.0.1", true), eFormat::HOST));
c.Add(Port(DASHBOARD, "net_port", "Server link port", "UDP port for the connection to master (the next one is used too). Keep clear of other servers' ports.", "2010"));
c.Add(Format(Text(DASHBOARD, "ugc_public_url", "UGC server address", "Where the browser loads previews from the UGC server, e.g. https://ugc.example.com. Empty: this host, port 2008.", ""), eFormat::URL));
c.Add(Format(Text(DASHBOARD, "dashboard_url", "Public address", "For links in emails and alerts, e.g. https://dashboard.example.com.", ""), eFormat::URL));
c.Add(Bool(DASHBOARD, "secure_cookies", "HTTPS only cookies", "Turn on when the dashboard is served over HTTPS.", false));
c.Add(Bool(DASHBOARD, "behind_proxy", "Behind a reverse proxy", "Use the proxy's X-Forwarded-For for rate limits. Only when the dashboard can't be reached directly.", false));

View File

@@ -0,0 +1,97 @@
#include "UgcRoutes.h"
#include "Database.h"
#include "Game.h"
#include "GeneralUtils.h"
#include "RouteUtils.h"
#include "Sd0.h"
#include "WSRoutes.h"
#include "dConfig.h"
#include "eHTTPMethod.h"
using namespace RouteUtils;
namespace {
constexpr uint32_t PAGE_SIZE = 50;
const char* StateName(IUgc::eProcessState state) {
switch (state) {
case IUgc::eProcessState::PENDING: return "pending";
case IUgc::eProcessState::DONE: return "done";
case IUgc::eProcessState::FAILED: return "failed";
}
return "unknown";
}
std::optional<IUgc::eProcessState> ParseState(const std::string& text) {
if (text == "pending") return IUgc::eProcessState::PENDING;
if (text == "done") return IUgc::eProcessState::DONE;
if (text == "failed") return IUgc::eProcessState::FAILED;
return std::nullopt;
}
nlohmann::json Counts(const std::vector<std::pair<IUgc::eProcessState, uint64_t>>& counts) {
nlohmann::json out = { { "pending", 0 }, { "done", 0 }, { "failed", 0 } };
for (const auto& [state, count] : counts) out[StateName(state)] = count;
return out;
}
}
namespace UgcRoutes {
void RegisterRoutes() {
Route(eHTTPMethod::GET, "/ugc", Perm("properties_view"), "What the UGC server made of players' models",
[](HTTPReply& reply, const HTTPContext& context) { RenderPage(reply, context, "ugc.jinja2", "ugc"); });
Route(eHTTPMethod::GET, "/api/ugc", Perm("properties_view"),
"Processing state: {counts: {model, modular: {pending, done, failed}}, items: [{id, characterId, characterName, state, attempts, processedAt, "
"error, bakeAo, modules}], ugcUrl, canManage}. Query: ?kind=model|modular&state=pending|done|failed&page=",
[](HTTPReply& reply, const HTTPContext& context) {
const bool modular = QueryValue(context.queryString, "kind") == "modular";
const auto state = ParseState(QueryValue(context.queryString, "state"));
const auto page = GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "page")).value_or(0);
const auto list = modular ? Database::Get()->GetModularBuildProcessList(state, page * PAGE_SIZE, PAGE_SIZE + 1)
: Database::Get()->GetUgcProcessList(state, page * PAGE_SIZE, PAGE_SIZE + 1);
nlohmann::json items = nlohmann::json::array();
for (size_t i = 0; i < list.size() && i < PAGE_SIZE; i++) {
const auto& info = list[i];
items.push_back({ { "id", std::to_string(info.id) }, { "characterId", std::to_string(info.characterId) }, { "characterName", info.characterName },
{ "state", StateName(info.state) }, { "attempts", info.attempts }, { "processedAt", info.processedAt }, { "error", info.error },
{ "bakeAo", info.bakeAo }, { "modules", info.details } });
}
JsonSuccess(reply, { { "counts", { { "model", Counts(Database::Get()->GetUgcProcessCounts()) }, { "modular", Counts(Database::Get()->GetModularBuildProcessCounts()) } } },
{ "items", items }, { "more", list.size() > PAGE_SIZE }, { "ugcUrl", Game::config->GetValue("ugc_public_url") }, { "canManage", Can(context, "ugc_manage") } });
});
Route(eHTTPMethod::GET, "/api/ugc/:id/lxfml", Perm("properties_view"), "A player model's LXFML",
[](HTTPReply& reply, const HTTPContext& context) {
const auto id = PathId<LWOOBJID>(context.path, 2);
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
auto model = Database::Get()->GetUgcModel(*id);
if (!model) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such model");
Sd0 sd0(model->lxfmlData);
reply.status = eHTTPStatusCode::OK;
reply.contentType = eContentType::TEXT_PLAIN;
reply.message = sd0.GetAsStringUncompressed();
});
Route(eHTTPMethod::POST, "/api/ugc/reprocess", Perm("ugc_manage"),
"Have the UGC server make items again. Body: {kind: model|modular, id} for one, {kind, failedOnly: true} for the failed ones, {kind} for all",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const bool modular = body->value("kind", "model") == "modular";
std::optional<LWOOBJID> id;
if (body->contains("id")) {
id = GeneralUtils::TryParse<LWOOBJID>((*body)["id"].is_string() ? (*body)["id"].get<std::string>() : (*body)["id"].dump());
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
}
const bool failedOnly = body->value("failedOnly", false);
const auto changed = modular ? Database::Get()->ResetModularBuildProcessing(id, failedOnly) : Database::Get()->ResetUgcModelProcessing(id, failedOnly);
const std::string what = modular ? "modular build" : "model";
Audit(context, "ugc_reprocess", "Queued " + std::to_string(changed) + " " + what + "(s) to be made again" +
(id ? " (" + std::to_string(*id) + ")" : failedOnly ? " (the failed ones)" : " (all)"));
BroadcastTableChanged("ugc");
JsonSuccess(reply, { { "message", std::to_string(changed) + " " + what + (changed == 1 ? "" : "s") + " will be made again" } });
});
}
}

View File

@@ -0,0 +1,6 @@
#pragma once
// The UGC page: what the UGC server has made of players' models and modular builds, and making them again
namespace UgcRoutes {
void RegisterRoutes();
}

View File

@@ -0,0 +1,118 @@
/**
* The UGC Server page: counts and a list of what the UGC server made of players' models and modular builds (from the
* database), its live status and previews (from the UGC server itself, at ugc_public_url), and making things again.
*/
(function () {
'use strict';
var kind = 'model', state = '', page = 0, ugcUrl = '', canManage = false, viewer = null;
var STATES = { pending: ['Waiting', 'secondary'], done: ['Made', 'success'], failed: ['Failed', 'danger'] };
function serverUrl() {
return (ugcUrl || (location.protocol + '//' + location.hostname + ':2008')).replace(/\/+$/, '');
}
function fileUrl(id, name) {
return serverUrl() + '/files/' + kind + '/' + encodeURIComponent(id) + '/' + name;
}
function countCard(title, c) {
return '<div class="col-md-6"><div class="card"><div class="card-body py-2"><div class="small text-body-secondary">' + esc(title) + '</div>' +
fmt.badge(c.done + ' made', 'success') + ' ' + fmt.badge(c.pending + ' waiting', 'secondary') + ' ' + fmt.badge(c.failed + ' failed', c.failed ? 'danger' : 'secondary') +
'</div></div></div>';
}
function row(i) {
var s = STATES[i.state] || [i.state, 'secondary'];
var icon = i.state === 'done' ? '<img src="' + esc(fileUrl(i.id, 'icon.png')) + '" width="48" height="48" loading="lazy" alt="" onerror="this.style.visibility=\'hidden\'">' : '';
var owner = i.characterName ? '<a href="/characters/' + esc(i.characterId) + '">' + esc(i.characterName) + '</a>' : '<span class="text-body-secondary">' + esc(i.characterId) + '</span>';
var details = kind === 'modular' ? '<code class="small">' + esc(i.modules) + '</code>' : '';
if (i.error) details += '<div class="small text-danger">' + esc(i.error) + '</div>';
var buttons = (kind === 'model' ? '<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-preview="' + esc(i.id) + '">View</button>' : '') +
(canManage ? '<button type="button" class="btn btn-sm btn-outline-warning" data-remake="' + esc(i.id) + '">Make again</button>' : '');
return '<tr><td>' + icon + '</td><td class="small">' + esc(i.id) + '</td><td>' + owner + '</td><td>' + fmt.badge(s[0], s[1]) +
(i.attempts ? ' <span class="small text-body-secondary">' + esc(i.attempts) + ' attempt' + (i.attempts === 1 ? '' : 's') + '</span>' : '') + '</td>' +
'<td class="small">' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + '</td><td>' + details + '</td><td class="text-end text-nowrap">' + buttons + '</td></tr>';
}
function load() {
api.get('/api/ugc?kind=' + kind + '&state=' + state + '&page=' + page).then(function (d) {
if (!d.success) return;
ugcUrl = d.ugcUrl;
canManage = d.canManage;
document.getElementById('manageButtons').classList.toggle('d-none', !canManage);
document.getElementById('counts').innerHTML = countCard('Models', d.counts.model) + countCard('Cars and rockets', d.counts.modular);
document.getElementById('rows').innerHTML = d.items.map(row).join('') || '<tr><td colspan="7" class="text-body-secondary">Nothing here.</td></tr>';
document.getElementById('prevPage').disabled = page === 0;
document.getElementById('nextPage').disabled = !d.more;
loadStatus();
}).catch(function () {});
}
function loadStatus() {
var box = document.getElementById('serverStatus');
fetch(serverUrl() + '/status').then(function (r) { return r.json(); }).then(function (s) {
var mb = function (b) { return (b / 1048576).toFixed(0) + ' MB'; };
var last = s.recent && s.recent[0];
box.innerHTML = fmt.badge('Online', 'success') + ' ' + esc(s.workers) + ' workers, ' + esc(s.active) + ' working, ' + esc(s.queued) + ' queued. ' +
'Since it started: ' + esc(s.made) + ' made, ' + esc(s.failed) + ' failed attempts. Files: ' + esc(mb(s.storedBytes)) +
(s.maxStorageBytes ? ' of ' + esc(mb(s.maxStorageBytes)) : '') + (s.evicted ? ', ' + esc(s.evicted) + ' deleted to save space' : '') + '.' +
(last ? '<div class="text-body-secondary">Last: ' + esc(last.kind) + ' ' + esc(last.id) + (last.ok ? ' made in ' + esc(last.ms) + ' ms' : ' failed') +
(last.message ? ' (' + esc(last.message) + ')' : '') + '</div>' : '');
}).catch(function () {
box.innerHTML = fmt.badge('Unreachable', 'warning') + ' The UGC server doesn\'t answer at ' + esc(serverUrl()) +
'. Is <code>enable_ugc_server</code> on, and <code>ugc_public_url</code> right?';
});
}
function remake(body, question) {
if (question && !confirm(question)) return;
body.kind = kind;
api.post('/api/ugc/reprocess', body).then(function (d) {
if (!d.success) { toast(d.error || 'Failed', 'danger'); return; }
toast(d.message, 'success');
load();
});
}
function preview(id) {
document.getElementById('previewTitle').textContent = 'Model ' + id;
document.getElementById('previewIcon').src = fileUrl(id, 'icon.png');
document.getElementById('previewLinks').innerHTML = '<a href="' + esc(fileUrl(id, 'model.nif')) + '">Download the mesh (.nif)</a><br>' +
'<a href="/api/ugc/' + esc(id) + '/lxfml" download="ugc_' + esc(id) + '.lxfml">Download the LXFML</a>';
bootstrap.Modal.getOrCreateInstance(document.getElementById('previewModal')).show();
var container = document.getElementById('previewViewer');
container.textContent = 'Loading…';
import('/js/lddviewer.js').then(function (module) {
if (viewer && viewer.dispose) viewer.dispose();
container.textContent = '';
viewer = module.createViewer(container, {});
return viewer.load([{ id: id, name: 'Model ' + id, position: [0, 0, 0], rotation: [0, 0, 0, 1], url: '/api/ugc/' + id + '/lxfml' }], 1);
}).catch(function (e) { container.textContent = 'The 3D view could not load: ' + e.message; });
}
document.getElementById('kindButtons').addEventListener('click', function (e) {
var button = e.target.closest('[data-kind]');
if (!button) return;
kind = button.dataset.kind;
page = 0;
this.querySelectorAll('[data-kind]').forEach(function (b) { b.classList.toggle('active', b === button); });
load();
});
document.getElementById('stateFilter').addEventListener('change', function () { state = this.value; page = 0; load(); });
document.getElementById('prevPage').addEventListener('click', function () { if (page > 0) { page--; load(); } });
document.getElementById('nextPage').addEventListener('click', function () { page++; load(); });
document.getElementById('retryFailed').addEventListener('click', function () { remake({ failedOnly: true }); });
document.getElementById('remakeAll').addEventListener('click', function () {
remake({}, 'Make every ' + (kind === 'model' ? 'model' : 'car and rocket') + ' again? This can take a long time.');
});
document.getElementById('rows').addEventListener('click', function (e) {
var remakeButton = e.target.closest('[data-remake]'), previewButton = e.target.closest('[data-preview]');
if (remakeButton) remake({ id: remakeButton.dataset.remake });
else if (previewButton) preview(previewButton.dataset.preview);
});
if (window.Live) Live.on('ugc', load);
load();
setInterval(loadStatus, 10000);
})();

View File

@@ -117,8 +117,8 @@
</div>
{% endif %}
{% set adminPages = ["settings", "settings_history", "permissions", "tasks", "play_keys", "backups", "maintenance", "webhooks", "client_assets"] %}
{% if can.settings or can.permissions_manage or can.tasks_view or can.play_keys_manage or can.backups or can.maintenance or can.webhooks or can.client_files %}
{% set adminPages = ["settings", "settings_history", "permissions", "tasks", "play_keys", "backups", "maintenance", "webhooks", "client_assets", "ugc"] %}
{% if can.settings or can.permissions_manage or can.tasks_view or can.play_keys_manage or can.backups or can.maintenance or can.webhooks or can.client_files or can.ugc_manage %}
<div class="nav-group">
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-admin" aria-expanded="{% if current_page in adminPages %}true{% else %}false{% endif %}" aria-controls="nav-admin">
Server Admin<span class="nav-group-dot" title="Something in here needs attention"></span>
@@ -132,6 +132,7 @@
{% if can.backups %}<a href="/backups" class="list-group-item list-group-item-action{% if current_page == "backups" %} active{% endif %}">Backups</a>{% endif %}
{% if can.maintenance %}<a href="/maintenance" class="list-group-item list-group-item-action{% if current_page == "maintenance" %} active{% endif %}">Maintenance</a>{% endif %}
{% if can.webhooks %}<a href="/webhooks" class="list-group-item list-group-item-action{% if current_page == "webhooks" %} active{% endif %}">Webhooks</a>{% endif %}
{% if can.ugc_manage %}<a href="/ugc" class="list-group-item list-group-item-action{% if current_page == "ugc" %} active{% endif %}">UGC Server</a>{% endif %}
{% if can.client_files %}<a href="/client_assets" class="list-group-item list-group-item-action{% if current_page == "client_assets" %} active{% endif %}">Client Files</a>{% endif %}
</div>
</div>

View File

@@ -0,0 +1,77 @@
{% extends "base.jinja2" %}
{% block title %}UGC Server - DarkflameServer{% endblock %}
{% block content %}
<div id="ugcPage">
<div class="mb-3">
<h2 class="mb-1">UGC Server</h2>
<p class="text-body-secondary mb-0">The UGC server makes a mesh and an icon for every model players build, and icons for their cars and rockets,
and serves them to the game client. New and changed models are picked up by themselves; a model that fails is tried a few times, then left
as failed until it's made again from here. See <code>docs/UgcServer.md</code>.</p>
</div>
<div class="row g-2 mb-3" id="counts"></div>
<div class="card mb-3">
<div class="card-body py-2 small" id="serverStatus">Asking the UGC server…</div>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<div class="btn-group btn-group-sm" role="group" id="kindButtons">
<button type="button" class="btn btn-outline-primary active" data-kind="model">Models</button>
<button type="button" class="btn btn-outline-primary" data-kind="modular">Cars and rockets</button>
</div>
<select class="form-select form-select-sm w-auto" id="stateFilter">
<option value="">Every state</option><option value="pending">Waiting</option><option value="done">Made</option><option value="failed">Failed</option>
</select>
<div class="ms-auto d-none" id="manageButtons">
<button type="button" class="btn btn-sm btn-outline-warning" id="retryFailed">Try the failed ones again</button>
<button type="button" class="btn btn-sm btn-outline-danger" id="remakeAll">Make everything again</button>
</div>
</div>
<div class="card">
<table class="table table-sm align-middle mb-0 table-stack">
<thead><tr><th>Icon</th><th>ID</th><th>Owner</th><th>State</th><th>Last attempt</th><th>Details</th><th></th></tr></thead>
<tbody id="rows"></tbody>
</table>
</div>
<div class="d-flex gap-2 mt-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="prevPage">Newer</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="nextPage">Older</button>
</div>
<div class="modal fade" id="previewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl"><div class="modal-content">
<div class="modal-header"><h5 class="modal-title" id="previewTitle">Model</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>
<div class="modal-body">
<div class="row g-3">
<div class="col-md-3 text-center"><img id="previewIcon" alt="Icon" class="border rounded bg-body-tertiary" width="128" height="128">
<div class="small mt-2" id="previewLinks"></div></div>
<div class="col-md-9"><div id="previewViewer" style="height: 420px"></div>
<div class="small text-body-secondary">The model as built (its LXFML).</div></div>
</div>
</div>
</div></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
},
"integrity": {
"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js": "sha384-IDC7sAMAIMB/TZ6dgKKPPAKZ2bXXXP8+FBMBC8cU319eBhKITx+PaalhfDkDNH28",
"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/controls/OrbitControls.js": "sha384-aJoe4qqS/DgF2jh9njAuvA6QIveJYoCuYOfYjdFY8P3eawzmc9bEQ40jq2TvOyuP",
"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/environments/RoomEnvironment.js": "sha384-9ItPPH2vgKbkvgrqTKq7ptGYe+WuAnqlZtlqleJW4/CJjnM5R7C+efD6iS7ADPJ9"
}
}
</script>
<script src="/js/ldd-materials.js"></script>
<script src="/js/ugc.js"></script>
{% endblock %}

View File

@@ -90,7 +90,7 @@ wanted.
## Database state
Migrations `dlu/mysql/80_ugc_processing.sql` and `dlu/sqlite/63_ugc_processing.sql` (the MySQL one only adds columns
Migrations `dlu/mysql/81_ugc_processing.sql` and `dlu/sqlite/64_ugc_processing.sql` (the MySQL one only adds columns
that aren't there yet).
`ugc`: `is_optimized` (existing) is 0 until the model has been processed, 1 once its files are made, 2 when processing
@@ -142,10 +142,12 @@ make one item's files into a folder without a database, for trying settings.
## Dashboard
The UGC page (`/ugc`) reads everything from the database: counts per state, a paged list of models and modular builds
with their state, attempts, last error and owner, and buttons to reprocess one model, all failed ones, or everything
(these only change the database columns). Previews (icon, mesh, LXFML) load from the UGC server's `/files/` URLs;
`ugc_public_url` in `dashboardconfig.ini` says where it is (default `http://<dashboard host>:2008`).
The UGC Server page (`/ugc`, Server Admin menu; `properties_view` to look, the new `ugc_manage` permission to make
things again) reads the database: counts per state for models and for cars and rockets, a paged list with owner,
state, attempts, last attempt and failure reason, and buttons to make one item, the failed ones or everything again
(these only reset the columns; the UGC server picks the rows up). Icons, the live status (`/status`) and the mesh
download come from the UGC server at `ugc_public_url` (`dashboardconfig.ini`; empty: the dashboard's host name on
port 2008). "View" shows the model's LXFML in the dashboard's 3D viewer (`/api/ugc/<id>/lxfml`).
## Not done yet

View File

@@ -50,6 +50,10 @@ enable_char_xml_upload=0
# Links are never built from the request's Host header, so this must be set explicitly.
dashboard_url=
# Where the browser loads previews (icons, meshes) from the UGC server, e.g. https://ugc.example.com
# Empty: the dashboard's host name on port 2008
ugc_public_url=
# SMTP server and port. Typical: 587 with starttls, or 465 with tls.
smtp_host=
smtp_port=587