Show pictures in descriptions, or links to them when turned off
[img] in a channel, client or server description is now a picture on both clients, as TeamSpeak shows it; chat keeps it a link. Only web addresses become pictures. While one loads it is TeamSpeak's loading icon; one that cannot be had is its address behind the broken-picture icon. "Show pictures in descriptions" (desktop: Chat settings; Android: Appearance) turns them into links behind a picture icon instead, fetching nothing. A picture that was itself a link keeps that link beside it. Core's PictureDownloads fetches them for both clients: kept on disk for a month after last shown, at most 8 MB, redirects followed across http and https, and on Android plain http asked for over https. The desktop draws them narrowed to the info panel, Android at a pixel to a dp, animated GIFs playing on both; long addresses are shortened to host and file name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -191,6 +191,8 @@ public final class Settings {
|
||||
public boolean chatLogs = true;
|
||||
/** Where chat logs go; empty for the TeamSpeak 3 client's own folder, so both share them. */
|
||||
public String chatLogDir = "";
|
||||
/** Whether descriptions show their pictures, fetched from the web, or only links to them. */
|
||||
public boolean descriptionPictures = true;
|
||||
|
||||
public static Settings load() {
|
||||
Settings s = new Settings();
|
||||
@@ -279,6 +281,7 @@ public final class Settings {
|
||||
notifications.load(props);
|
||||
contactDefaults.load(props);
|
||||
colorContacts = parseB(props.getProperty("colorContacts"), colorContacts);
|
||||
descriptionPictures = parseB(props.getProperty("descriptionPictures"), descriptionPictures);
|
||||
whisperPolicy = parseEnum(WhisperPolicy.class, props.getProperty("whisperPolicy"), whisperPolicy);
|
||||
chatLogs = parseB(props.getProperty("chatLogs"), chatLogs);
|
||||
chatLogDir = props.getProperty("chatLogDir", chatLogDir);
|
||||
@@ -333,6 +336,7 @@ public final class Settings {
|
||||
notifications.store(props);
|
||||
contactDefaults.store(props);
|
||||
props.setProperty("colorContacts", Boolean.toString(colorContacts));
|
||||
props.setProperty("descriptionPictures", Boolean.toString(descriptionPictures));
|
||||
props.setProperty("whisperPolicy", whisperPolicy.name());
|
||||
props.setProperty("chatLogs", Boolean.toString(chatLogs));
|
||||
props.setProperty("chatLogDir", chatLogDir);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* The pictures descriptions show, fetched from the web on first sight and kept on disk
|
||||
* for a month after they were last shown.
|
||||
*
|
||||
* <p>Lookups never block: {@link #get} returns {@code null} while a picture is on its
|
||||
* way, and the listeners hear when one has come or could not be had.
|
||||
*/
|
||||
public final class PictureDownloads {
|
||||
|
||||
private static final long MAX_BYTES = 8L << 20;
|
||||
private static final int TIMEOUT_MS = 10_000;
|
||||
private static final long KEEP_MS = 30L * 24 * 60 * 60 * 1000;
|
||||
private static final String USER_AGENT = "ts3client";
|
||||
private static final int MAX_REDIRECTS = 5;
|
||||
|
||||
private final File dir;
|
||||
private final boolean plainHttp;
|
||||
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
|
||||
private final Set<String> pending = ConcurrentHashMap.newKeySet();
|
||||
private final Set<String> failed = ConcurrentHashMap.newKeySet();
|
||||
private final ExecutorService downloads = Executors.newFixedThreadPool(2, r -> {
|
||||
Thread t = new Thread(r, "picture-download");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
/**
|
||||
* @param plainHttp whether plain http may be used; where it may not (Android), an
|
||||
* http address is fetched over https, which most hosts also serve
|
||||
*/
|
||||
public PictureDownloads(File dir, boolean plainHttp) {
|
||||
this.dir = dir;
|
||||
this.plainHttp = plainHttp;
|
||||
downloads.execute(this::prune);
|
||||
}
|
||||
|
||||
/** @param listener run off the UI thread whenever a picture has come or failed */
|
||||
public void addListener(Runnable listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
public void removeListener(Runnable listener) {
|
||||
listeners.remove(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the picture's file, or {@code null} while it downloads or when it cannot be
|
||||
* had (see {@link #failed}). A picture not on disk yet starts downloading.
|
||||
*/
|
||||
public File get(String url) {
|
||||
File file = new File(dir, name(url));
|
||||
if (file.isFile()) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
file.setLastModified(System.currentTimeMillis());
|
||||
return file;
|
||||
}
|
||||
if (!failed.contains(url) && pending.add(url)) downloads.execute(() -> download(url, file));
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether the picture could not be had this session; it is not asked for again until then. */
|
||||
public boolean failed(String url) {
|
||||
return failed.contains(url);
|
||||
}
|
||||
|
||||
private void download(String url, File file) {
|
||||
try {
|
||||
byte[] data = fetch(allowed(url));
|
||||
if (data == null) {
|
||||
failed.add(url);
|
||||
} else {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
dir.mkdirs();
|
||||
File part = new File(dir, file.getName() + ".part");
|
||||
Files.write(part.toPath(), data);
|
||||
Files.move(part.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
failed.add(url);
|
||||
} finally {
|
||||
pending.remove(url);
|
||||
}
|
||||
for (Runnable listener : listeners) listener.run();
|
||||
}
|
||||
|
||||
/** The address as it may be fetched here: over https where plain http is not allowed. */
|
||||
private String allowed(String url) {
|
||||
boolean http = url.regionMatches(true, 0, "http://", 0, 7);
|
||||
return plainHttp || !http ? url : "https://" + url.substring(7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows redirects itself: Java does not follow one that changes protocol, which is how
|
||||
* many hosts send plain http on to https.
|
||||
*/
|
||||
private byte[] fetch(String url) throws IOException {
|
||||
for (int hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
|
||||
try {
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
connection.setConnectTimeout(TIMEOUT_MS);
|
||||
connection.setReadTimeout(TIMEOUT_MS);
|
||||
// Some hosts turn away Java's own agent (Wikimedia does).
|
||||
connection.setRequestProperty("User-Agent", USER_AGENT);
|
||||
int code = connection.getResponseCode();
|
||||
String location = connection.getHeaderField("Location");
|
||||
if (code / 100 == 3 && location != null) {
|
||||
String next = new URL(new URL(url), location).toString();
|
||||
if (!next.regionMatches(true, 0, "http", 0, 4)) return null;
|
||||
url = allowed(next);
|
||||
continue;
|
||||
}
|
||||
return code == HttpURLConnection.HTTP_OK ? read(connection) : null;
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] read(HttpURLConnection connection) throws IOException {
|
||||
if (connection.getContentLengthLong() > MAX_BYTES) return null;
|
||||
try (InputStream in = connection.getInputStream()) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[16 * 1024];
|
||||
for (int n; (n = in.read(buffer)) >= 0; ) {
|
||||
out.write(buffer, 0, n);
|
||||
if (out.size() > MAX_BYTES) return null;
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/** Forgets the pictures not shown for a month. */
|
||||
private void prune() {
|
||||
File[] files = dir.listFiles();
|
||||
if (files == null) return;
|
||||
long cutoff = System.currentTimeMillis() - KEEP_MS;
|
||||
for (File f : files) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
if (f.lastModified() < cutoff) f.delete();
|
||||
}
|
||||
}
|
||||
|
||||
private static String name(String url) {
|
||||
try {
|
||||
byte[] hash = MessageDigest.getInstance("SHA-1").digest(url.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : hash) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ public final class BBCode {
|
||||
Pattern.compile("\\[url]([^\\[\\]]+)\\[/url]", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern BARE_IMG_TAG =
|
||||
Pattern.compile("\\[img]([^\\[\\]]+)\\[/img]", Pattern.CASE_INSENSITIVE);
|
||||
/** A picture's address: only the web, so a description cannot point the client anywhere else. */
|
||||
private static final Pattern IMAGE_URL = Pattern.compile("(?i)https?://[^\\s\"'<>]+");
|
||||
private static final int MAX_ADDRESS = 48;
|
||||
private static final Pattern IMG_CLOSE = Pattern.compile("(?i)\\[/img]");
|
||||
private static final Pattern PLAIN_URL =
|
||||
Pattern.compile("(?i)\\b(?:https?://|www\\.)[^\\s<>\"']+");
|
||||
private static final Pattern COLOR = Pattern.compile("#[0-9a-fA-F]{3,6}|[a-zA-Z]{3,20}");
|
||||
@@ -44,11 +48,19 @@ public final class BBCode {
|
||||
private BBCode() {
|
||||
}
|
||||
|
||||
/** Converts BBCode to an HTML fragment (no surrounding {@code <html>} element). */
|
||||
/** Converts BBCode to an HTML fragment (no surrounding {@code <html>} element), pictures as links. */
|
||||
public static String toHtml(String input) {
|
||||
return toHtml(input, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param images whether {@code [img]} becomes an {@code <img>}, as TeamSpeak shows it in
|
||||
* descriptions, rather than a link to the picture, as in chat
|
||||
*/
|
||||
public static String toHtml(String input, boolean images) {
|
||||
if (input == null || input.isEmpty()) return "";
|
||||
String src = BARE_IMG_TAG.matcher(BARE_URL_TAG.matcher(input).replaceAll("[url=$1]$1[/url]"))
|
||||
.replaceAll("[url=$1]$1[/url]");
|
||||
String src = BARE_URL_TAG.matcher(input).replaceAll("[url=$1]$1[/url]");
|
||||
if (!images) src = BARE_IMG_TAG.matcher(src).replaceAll("[url=$1]$1[/url]");
|
||||
|
||||
StringBuilder out = new StringBuilder();
|
||||
Deque<Open> open = new ArrayDeque<>();
|
||||
@@ -65,6 +77,13 @@ public final class BBCode {
|
||||
text(out, src.substring(lb), open);
|
||||
break;
|
||||
}
|
||||
if (images && src.substring(lb + 1, rb).equalsIgnoreCase("img")) {
|
||||
int end = image(out, src, rb + 1);
|
||||
if (end > 0) {
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!emitTag(out, src.substring(lb + 1, rb), open)) {
|
||||
text(out, src.substring(lb, rb + 1), open);
|
||||
}
|
||||
@@ -102,6 +121,16 @@ public final class BBCode {
|
||||
return out.append(escape(input.substring(last))).toString();
|
||||
}
|
||||
|
||||
/** A long web address as its host and file name, for a link's label where room is short. */
|
||||
public static String shortAddress(String url) {
|
||||
if (url.length() <= MAX_ADDRESS) return url;
|
||||
int host = url.indexOf("://");
|
||||
int path = host < 0 ? -1 : url.indexOf('/', host + 3);
|
||||
int file = url.lastIndexOf('/');
|
||||
if (path < 0 || file <= path) return url;
|
||||
return url.substring(host + 3, path) + "/…" + url.substring(file);
|
||||
}
|
||||
|
||||
/** Escapes plain text for HTML without interpreting any BBCode. */
|
||||
public static String escape(String s) {
|
||||
if (s == null) return "";
|
||||
@@ -109,6 +138,20 @@ public final class BBCode {
|
||||
.replace("\"", """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the picture whose address starts at {@code from}, up to its {@code [/img]}.
|
||||
*
|
||||
* @return where the text goes on after it, or -1 when there is no web address to show
|
||||
*/
|
||||
private static int image(StringBuilder out, String src, int from) {
|
||||
Matcher close = IMG_CLOSE.matcher(src);
|
||||
if (!close.find(from)) return -1;
|
||||
String url = src.substring(from, close.start()).trim();
|
||||
if (!IMAGE_URL.matcher(url).matches()) return -1;
|
||||
out.append("<img src=\"").append(escape(url)).append("\">");
|
||||
return close.end();
|
||||
}
|
||||
|
||||
/** True once the tag was recognised and written; false to render it literally. */
|
||||
private static boolean emitTag(StringBuilder out, String tag, Deque<Open> open) {
|
||||
if (tag.isEmpty()) return false;
|
||||
@@ -171,6 +214,7 @@ public final class BBCode {
|
||||
if (!SAFE_URL.matcher(value).matches()) return false;
|
||||
return push(out, open, name, link(value), linkClose(value));
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.ts3client.text;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
/** The block tags TeamSpeak's own client renders in descriptions. */
|
||||
class BBCodeTagsTest {
|
||||
@@ -31,4 +32,36 @@ class BBCodeTagsTest {
|
||||
BBCode.toHtml("[table][tr][th]h[/th][td]d[/td][/tr][/table]"));
|
||||
assertEquals("[td]loose[/td]", BBCode.toHtml("[td]loose[/td]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void showsPicturesOnlyWhereAsked() {
|
||||
String bb = "[img]https://example.com/a.png[/img]";
|
||||
assertEquals("<img src=\"https://example.com/a.png\">", BBCode.toHtml(bb, true));
|
||||
assertEquals("<a class=\"extlink\" href=\"https://example.com/a.png\">https://example.com/a.png</a>",
|
||||
BBCode.toHtml(bb));
|
||||
}
|
||||
|
||||
@Test
|
||||
void linksPicturesInsideALink() {
|
||||
assertEquals("<a class=\"extlink\" href=\"https://x.org\"><img src=\"http://x.org/b.gif\"></a>",
|
||||
BBCode.toHtml("[url=https://x.org][img]http://x.org/b.gif[/img][/url]", true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void showsNoPictureFromElsewhereThanTheWeb() {
|
||||
assertEquals("[img]file:///etc/passwd[/img]", BBCode.toHtml("[img]file:///etc/passwd[/img]", true));
|
||||
assertFalse(BBCode.toHtml("[img=https://x.org/a.png]", true).contains("<img"), "only [img]…[/img] is a picture");
|
||||
}
|
||||
|
||||
@Test
|
||||
void escapesThePictureAddress() {
|
||||
assertEquals("<img src=\"https://x.org/a.png?a=1&b=2\">", BBCode.toHtml("[img]https://x.org/a.png?a=1&b=2[/img]", true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shortensLongAddressesToHostAndFile() {
|
||||
assertEquals("https://x.org/a.png", BBCode.shortAddress("https://x.org/a.png"));
|
||||
assertEquals("upload.wikimedia.org/…/250px-Example.png", BBCode.shortAddress(
|
||||
"https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/Example.png/250px-Example.png"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user