Skip to content
Merged
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
3 changes: 3 additions & 0 deletions cr-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ dependencies {
implementation("com.fasterxml.jackson.core:jackson-annotations")
implementation("org.apache.httpcomponents:httpclient:4.5.13")
implementation("org.apache.httpcomponents:httpmime:4.5.13")
// GitHub issue-creation API request/response bodies (GitHubIssueApiClient) - small,
// dependency-free, no reason to hand-roll JSON escaping/parsing instead.
implementation("org.json:json:20260814")

testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.1")
testImplementation("org.junit.jupiter:junit-jupiter-params:5.10.1")
Expand Down
1 change: 1 addition & 0 deletions cr-core/gradle.lockfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.checkerframework:checker-qual:2.5.2=pmd
org.checkerframework:checker-qual:3.12.0=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.json:json:20260814=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:5.10.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:5.10.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:5.10.1=testCompileClasspath,testRuntimeClasspath
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,29 @@ public enum KEY {
SUPPORT_FORUM_LINK,
JOIN_DISCORD_LINK,
REPORT_ISSUE_LINK,
REPORT_ISSUE_TEMPLATE,
// Client ID of a GitHub App (not an OAuth App) with Device Flow enabled and Issues write
// permission. Unset by default - see GitHubDeviceLogin's javadoc for why a GitHub App.
// When unset, the "submit directly" button is hidden.
REPORT_ISSUE_OAUTH_CLIENT_ID,
// Field IDs of the issue form named by REPORT_ISSUE_TEMPLATE, one per thing CrashSummary
// can pre-fill. Unset means that field is left for the user. Form-specific, so these live
// with the template in the downstream app's properties, never in cr-core's defaults.
REPORT_ISSUE_FIELD_VERSION,
REPORT_ISSUE_FIELD_OS,
REPORT_ISSUE_FIELD_JAVA,
REPORT_ISSUE_FIELD_DETAILS,
REPORT_ISSUE_FIELD_LOG,
REPORT_ISSUE_FIELD_EXTRA,

// What CrashSummary knows about the hosting application's logs. The regexes each capture
// one group from the combined log text; unset means that line is not extracted or shown.
// Log formats are app-specific and not a published API, which is why they are configured
// by the app (cr-terasology, cr-destsol) rather than hardcoded in cr-core.
CRASH_SUMMARY_PRODUCT_NAME,
CRASH_SUMMARY_VERSION_PATTERN,
CRASH_SUMMARY_DISPLAY_VERSION_PATTERN,
CRASH_SUMMARY_MODULE_PATTERN,

RES_BANNER_IMAGE,
RES_SERVER_ICON,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,13 @@ public String get() {
}
});
pages.add(uploadPanel);
pages.add(new FinalActionsPanel(properties, new Supplier<URL>() {
pages.add(new FinalActionsPanel(properties, exception, new Supplier<String>() {

@Override
public String get() {
return errorMessagePanel.getLog();
}
}, new Supplier<URL>() {

@Override
public URL get() {
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@
import javax.swing.JPanel;
import javax.swing.JTextArea;
import javax.swing.SwingConstants;
import javax.swing.SwingUtilities;
import javax.swing.border.EmptyBorder;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.GridLayout;
import java.awt.Toolkit;
import java.awt.Window;
import java.awt.datatransfer.Clipboard;
import java.awt.datatransfer.StringSelection;
import java.awt.event.ActionEvent;
Expand All @@ -35,6 +37,10 @@ public class FinalActionsPanel extends JPanel {

private static final long serialVersionUID = 2639334979749507943L;

private final Throwable exception;

private final Supplier<String> logTextSupplier;

private final Supplier<URL> uploadedFile;

private final JTextArea linkText;
Expand All @@ -43,9 +49,13 @@ public class FinalActionsPanel extends JPanel {

private boolean pageComplete;

public FinalActionsPanel(GlobalProperties properties, Supplier<URL> uploadedFile) {
public FinalActionsPanel(GlobalProperties properties, Throwable exception, Supplier<String> logTextSupplier,
Supplier<URL> uploadedFile) {

this.exception = exception;
this.logTextSupplier = logTextSupplier;
this.uploadedFile = uploadedFile;
final CrashSummary.Profile summaryProfile = CrashSummary.Profile.from(properties);

setLayout(new BorderLayout(0, 10));
setBorder(new EmptyBorder(0, 10, 10, 10));
Expand Down Expand Up @@ -85,14 +95,44 @@ public void actionPerformed(ActionEvent e) {

@Override
public void actionPerformed(ActionEvent e) {
BrowserLauncher.open(properties.get(KEY.REPORT_ISSUE_LINK));
CrashSummary summary = CrashSummary.extract(exception, logTextSupplier.get(), summaryProfile);
String baseUrl = properties.get(KEY.REPORT_ISSUE_LINK);
String template = properties.get(KEY.REPORT_ISSUE_TEMPLATE);
String link;
if (template != null && !template.isEmpty()) {
// The downstream app has its own issue *form* - land the summary in its real
// fields instead of overwriting the whole thing with a bespoke body.
link = GitHubIssueLinkBuilder.build(baseUrl, template, summary.buildTitle(),
summary.buildIssueFormFields(uploadedFile.get()));
} else {
link = GitHubIssueLinkBuilder.build(baseUrl, summary.buildTitle(),
summary.buildBody(uploadedFile.get()));
}
BrowserLauncher.open(link);
pageComplete = true;
firePropertyChange("pageComplete", !pageComplete, pageComplete);
}
});
githubIssueButton.setToolTipText(properties.get(KEY.REPORT_ISSUE_LINK));
gridPanel.add(githubIssueButton);

String oauthClientId = properties.get(KEY.REPORT_ISSUE_OAUTH_CLIENT_ID);
String[] ownerRepo = GitHubIssueApiClient.parseOwnerRepo(properties.get(KEY.REPORT_ISSUE_LINK));
if (oauthClientId != null && !oauthClientId.isEmpty() && ownerRepo != null) {
JButton submitDirectlyButton = new JButton(I18N.getMessage("reportIssueDirectly"));
submitDirectlyButton.setFont(buttonFont);
submitDirectlyButton.setIcon(Resources.loadIcon(properties.get(KEY.RES_GITHUB_ICON)));
submitDirectlyButton.addActionListener(e -> {
CrashSummary summary = CrashSummary.extract(exception, logTextSupplier.get(), summaryProfile);
Window window = SwingUtilities.getWindowAncestor(this);
new GitHubLoginDialog(window, oauthClientId, ownerRepo[0], ownerRepo[1],
summary.buildTitle(), summary.buildBody(uploadedFile.get())).setVisible(true);
pageComplete = true;
firePropertyChange("pageComplete", !pageComplete, pageComplete);
});
gridPanel.add(submitDirectlyButton);
}

JButton forumButton = new JButton(I18N.getMessage("gotoForum"));
forumButton.setIcon(Resources.loadIcon(properties.get(KEY.RES_FORUM_ICON)));
forumButton.setFont(buttonFont);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// Copyright 2026 The Terasology Foundation
// SPDX-License-Identifier: Apache-2.0

package org.terasology.crashreporter.pages;

import org.apache.http.NameValuePair;
import org.apache.http.StatusLine;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
* GitHub's OAuth Device Flow - no client secret, no redirect URI, made for apps like this one.
* Needs a <em>GitHub App</em> with Device Flow enabled, the "Issues: read and write" repository
* permission, and an installation on the repository that receives the reports; its client ID goes
* in {@link org.terasology.crashreporter.GlobalProperties.KEY#REPORT_ISSUE_OAUTH_CLIENT_ID}.
* <p>
* A GitHub App rather than an OAuth App on purpose. The token a user grants is limited to what
* the app may do, on the repositories it is installed on, and to what that user could already do
* there - for a player with no role on the repo, opening an issue. An OAuth App has no scope
* narrower than {@code public_repo}, which is write access to every public repository the user
* owns. GitHub Apps ignore the {@code scope} parameter, so none is sent.
* <p>
* Endpoints reply form-urlencoded by default, so no JSON parsing needed here.
*/
public final class GitHubDeviceLogin {

private static final String DEVICE_CODE_URL = "https://github.com/login/device/code";
private static final String TOKEN_URL = "https://github.com/login/oauth/access_token";

private GitHubDeviceLogin() {
}

public static DeviceCode requestDeviceCode(CloseableHttpClient client, String clientId) throws IOException {
Map<String, String> fields = post(client, DEVICE_CODE_URL, param("client_id", clientId));
failOnError(fields);
return new DeviceCode(required(fields, "device_code"), required(fields, "user_code"),
required(fields, "verification_uri"), requiredInt(fields, "expires_in"), requiredInt(fields, "interval"));
}

// A rate-limited or erroring endpoint answers with HTML or JSON, not the form fields this
// expects. Reporting that as an IOException keeps it on the path the dialog shows to the user;
// a NullPointerException or NumberFormatException here used to kill the login thread silently.
private static String required(Map<String, String> fields, String name) throws IOException {
String value = fields.get(name);
if (value == null || value.isEmpty()) {
throw new IOException("GitHub's response is missing '" + name + "': " + fields);
}
return value;
}

private static int requiredInt(Map<String, String> fields, String name) throws IOException {
try {
return Integer.parseInt(required(fields, name));
} catch (NumberFormatException e) {
throw new IOException("GitHub's response has a non-numeric '" + name + "': " + fields.get(name), e);
}
}

/** Blocks until authorized, denied, or expired. Call off the UI thread. */
public static String pollForAccessToken(CloseableHttpClient client, String clientId, DeviceCode code)
throws IOException, InterruptedException {
int interval = code.intervalSeconds;
long deadline = System.currentTimeMillis() + code.expiresInSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
Thread.sleep(interval * 1000L);
Map<String, String> fields = post(client, TOKEN_URL,
param("client_id", clientId), param("device_code", code.deviceCode),
param("grant_type", "urn:ietf:params:oauth:grant-type:device_code"));
String token = fields.get("access_token");
if (token != null) {
return token;
}
String error = fields.get("error");
if ("authorization_pending".equals(error)) {
continue;
}
if ("slow_down".equals(error)) {
interval += 5;
continue;
}
failOnError(fields);
// Neither a token nor an error: not a response this flow defines. Polling on would
// leave the user staring at the dialog until the device code expires.
throw new IOException("GitHub's token response had neither access_token nor error: " + fields);
}
throw new IOException("Device code expired");
}

private static void failOnError(Map<String, String> fields) throws IOException {
String error = fields.get("error");
if (error != null) {
throw new IOException(fields.getOrDefault("error_description", error));
}
}

private static Map<String, String> post(CloseableHttpClient client, String url, NameValuePair... params)
throws IOException {
HttpPost post = new HttpPost(url);
post.setHeader("Accept", "application/x-www-form-urlencoded");
List<NameValuePair> paramList = new ArrayList<>();
for (NameValuePair param : params) {
paramList.add(param);
}
post.setEntity(new UrlEncodedFormEntity(paramList, StandardCharsets.UTF_8));
try (CloseableHttpResponse response = client.execute(post)) {
String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
// The OAuth endpoints report expected conditions (authorization_pending, access_denied)
// in the form body with a 200, so only a genuinely failed request is rejected here.
StatusLine status = response.getStatusLine();
if (status != null && status.getStatusCode() / 100 != 2) {
throw new IOException("GitHub login endpoint returned HTTP " + status.getStatusCode() + ": " + body);
}
return parseFormBody(body);
}
}

private static NameValuePair param(String name, String value) {
return new BasicNameValuePair(name, value);
}

static Map<String, String> parseFormBody(String body) {
Map<String, String> result = new LinkedHashMap<>();
for (String pair : body.split("&")) {
if (pair.isEmpty()) {
continue;
}
int eq = pair.indexOf('=');
String key = eq >= 0 ? pair.substring(0, eq) : pair;
String value = eq >= 0 ? pair.substring(eq + 1) : "";
result.put(decode(key), decode(value));
}
return result;
}

private static String decode(String value) {
try {
return URLDecoder.decode(value, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new AssertionError(e);
}
}

public static final class DeviceCode {
final String deviceCode;
final String userCode;
final String verificationUri;
final int expiresInSeconds;
final int intervalSeconds;

DeviceCode(String deviceCode, String userCode, String verificationUri, int expiresInSeconds, int intervalSeconds) {
this.deviceCode = deviceCode;
this.userCode = userCode;
this.verificationUri = verificationUri;
this.expiresInSeconds = expiresInSeconds;
this.intervalSeconds = intervalSeconds;
}

public String getUserCode() {
return userCode;
}

public String getVerificationUri() {
return verificationUri;
}
}
}
Loading