Skip to content
Open
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 @@ -64,6 +64,9 @@ dependencies {
implementation("org:jpastebin:1.0.1")
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ public enum KEY {
SUPPORT_FORUM_LINK,
JOIN_DISCORD_LINK,
REPORT_ISSUE_LINK,
REPORT_ISSUE_TEMPLATE,
// GitHub OAuth App client ID with Device Flow enabled. Unset by default - see
// GitHubDeviceLogin's javadoc. When unset, the "submit directly" button is hidden.
REPORT_ISSUE_OAUTH_CLIENT_ID,

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,13 +15,15 @@
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.Desktop;
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 @@ -39,6 +41,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 @@ -47,8 +53,11 @@ 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;

setLayout(new BorderLayout(0, 10));
Expand Down Expand Up @@ -89,14 +98,44 @@ public void actionPerformed(ActionEvent e) {

@Override
public void actionPerformed(ActionEvent e) {
openInBrowser(properties.get(KEY.REPORT_ISSUE_LINK));
CrashSummary summary = CrashSummary.extract(exception, logTextSupplier.get());
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()));
}
openInBrowser(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());
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,144 @@
// 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.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 an OAuth App registered at github.com/settings/developers with Device Flow enabled;
* its client ID goes in {@link org.terasology.crashreporter.GlobalProperties.KEY#REPORT_ISSUE_OAUTH_CLIENT_ID}.
* 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 static final String SCOPE = "public_repo";

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), param("scope", SCOPE));
failOnError(fields);
return new DeviceCode(fields.get("device_code"), fields.get("user_code"), fields.get("verification_uri"),
Integer.parseInt(fields.get("expires_in")), Integer.parseInt(fields.get("interval")));
}

/** 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);
}
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);
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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright 2026 The Terasology Foundation
// SPDX-License-Identifier: Apache-2.0

package org.terasology.crashreporter.pages;

import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.util.EntityUtils;
import org.json.JSONObject;

import java.io.IOException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* Creates a GitHub issue via the REST API - a POST body, not a URL query string, so no length
* limit (unlike {@link GitHubIssueLinkBuilder}). Needs an access token from
* {@link GitHubDeviceLogin}.
*/
public final class GitHubIssueApiClient {

private static final Pattern OWNER_REPO_PATTERN = Pattern.compile("github\\.com/([^/]+)/([^/]+)/");

private GitHubIssueApiClient() {
}

/**
* @return the created issue's URL
*/
public static URL createIssue(CloseableHttpClient client, String token, String owner, String repo,
String title, String body) throws IOException {
HttpPost post = new HttpPost("https://api.github.com/repos/" + owner + "/" + repo + "/issues");
post.setHeader("Authorization", "Bearer " + token);
post.setHeader("Accept", "application/vnd.github+json");
post.setHeader("X-GitHub-Api-Version", "2022-11-28");
JSONObject requestJson = new JSONObject();
requestJson.put("title", title);
requestJson.put("body", body);
post.setEntity(new StringEntity(requestJson.toString(), ContentType.APPLICATION_JSON));

try (CloseableHttpResponse response = client.execute(post)) {
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
if (response.getStatusLine().getStatusCode() != HttpStatus.SC_CREATED) {
throw new IOException("GitHub API error " + response.getStatusLine().getStatusCode() + ": " + responseBody);
}
return new URL(new JSONObject(responseBody).getString("html_url"));
}
}

/**
* @return {@code {owner, repo}} parsed from a link like
* {@code https://github.com/MovingBlocks/Terasology/issues/new}, or {@code null} if
* it doesn't look like a github.com repo link
*/
public static String[] parseOwnerRepo(String reportIssueLink) {
if (reportIssueLink == null) {
return null;
}
Matcher matcher = OWNER_REPO_PATTERN.matcher(reportIssueLink);
return matcher.find() ? new String[] {matcher.group(1), matcher.group(2)} : null;
}
}
Loading