Security Fixes
This commit is contained in:
parent
aa7a7bc9fc
commit
a0eebfcb3f
14 changed files with 279 additions and 37 deletions
|
|
@ -51,17 +51,14 @@ public class AuthController {
|
||||||
.body(null);
|
.body(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check registration password if configured
|
// Check registration password if configured.
|
||||||
// Check for both null and blank (empty or whitespace-only strings)
|
// NEVER log the configured or provided password values — they are credentials.
|
||||||
log.debug("Registration password check - configured: '{}', provided: '{}'",
|
|
||||||
configuredRegistrationPassword, request.getRegistrationPassword());
|
|
||||||
|
|
||||||
if (configuredRegistrationPassword != null && !configuredRegistrationPassword.trim().isEmpty()) {
|
if (configuredRegistrationPassword != null && !configuredRegistrationPassword.trim().isEmpty()) {
|
||||||
String providedPassword = request.getRegistrationPassword();
|
String providedPassword = request.getRegistrationPassword();
|
||||||
if (providedPassword == null || providedPassword.trim().isEmpty() ||
|
if (providedPassword == null || providedPassword.trim().isEmpty() ||
|
||||||
!configuredRegistrationPassword.equals(providedPassword)) {
|
!configuredRegistrationPassword.equals(providedPassword)) {
|
||||||
log.warn("Registration attempt with invalid registration password for username: {} (expected: '{}', got: '{}')",
|
log.warn("Registration attempt with invalid registration password for username: {}",
|
||||||
request.getUsername(), configuredRegistrationPassword, providedPassword);
|
request.getUsername());
|
||||||
throw new IllegalArgumentException("Invalid registration password");
|
throw new IllegalArgumentException("Invalid registration password");
|
||||||
}
|
}
|
||||||
log.info("Registration password validated successfully for username: {}", request.getUsername());
|
log.info("Registration password validated successfully for username: {}", request.getUsername());
|
||||||
|
|
|
||||||
|
|
@ -100,14 +100,20 @@ public class TimelineController {
|
||||||
@AuthenticationPrincipal(errorOnInvalidType = false) UserDetails userDetails,
|
@AuthenticationPrincipal(errorOnInvalidType = false) UserDetails userDetails,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size,
|
@RequestParam(defaultValue = "20") int size,
|
||||||
@RequestParam(required = false) String search
|
@RequestParam(required = false) String search,
|
||||||
|
@RequestParam(required = false) String hashtag
|
||||||
) {
|
) {
|
||||||
UUID userId = null;
|
UUID userId = null;
|
||||||
if (userDetails != null) {
|
if (userDetails != null) {
|
||||||
userId = getUserId(userDetails);
|
userId = getUserId(userDetails);
|
||||||
log.debug("Public timeline request from authenticated user: {} (search: {})", userId, search);
|
log.debug("Public timeline request from authenticated user: {} (search: {}, hashtag: {})", userId, search, hashtag);
|
||||||
} else {
|
} else {
|
||||||
log.debug("Public timeline request (unauthenticated) (search: {})", search);
|
log.debug("Public timeline request (unauthenticated) (search: {}, hashtag: {})", search, hashtag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject malformed hashtags (extraction only allows \w)
|
||||||
|
if (hashtag != null && !hashtag.matches("\\w+")) {
|
||||||
|
hashtag = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by activity start date descending (latest first)
|
// Sort by activity start date descending (latest first)
|
||||||
|
|
@ -115,9 +121,9 @@ public class TimelineController {
|
||||||
|
|
||||||
// Use search if filters provided, otherwise use standard timeline
|
// Use search if filters provided, otherwise use standard timeline
|
||||||
Page<TimelineActivityDTO> timeline;
|
Page<TimelineActivityDTO> timeline;
|
||||||
if (search != null) {
|
if (search != null || hashtag != null) {
|
||||||
timeline = timelineService.searchPublicTimeline(
|
timeline = timelineService.searchPublicTimeline(
|
||||||
userId, search, pageable
|
userId, search, hashtag, pageable
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
timeline = timelineService.getPublicTimeline(userId, pageable);
|
timeline = timelineService.getPublicTimeline(userId, pageable);
|
||||||
|
|
|
||||||
|
|
@ -369,6 +369,10 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
|
||||||
OR LOWER(a.description) LIKE LOWER(CONCAT('%', :searchText, '%'))
|
OR LOWER(a.description) LIKE LOWER(CONCAT('%', :searchText, '%'))
|
||||||
OR LOWER(a.activity_location) LIKE LOWER(CONCAT('%', :searchText, '%'))
|
OR LOWER(a.activity_location) LIKE LOWER(CONCAT('%', :searchText, '%'))
|
||||||
))
|
))
|
||||||
|
AND (CAST(:hashtagPattern AS text) IS NULL OR (
|
||||||
|
a.title ~* CAST(:hashtagPattern AS text)
|
||||||
|
OR a.description ~* CAST(:hashtagPattern AS text)
|
||||||
|
))
|
||||||
GROUP BY a.id, a.user_id, a.activity_type, a.title, a.description, a.started_at, a.ended_at,
|
GROUP BY a.id, a.user_id, a.activity_type, a.title, a.description, a.started_at, a.ended_at,
|
||||||
a.timezone, a.visibility, a.total_distance, a.total_duration_seconds, a.elevation_gain, a.elevation_loss,
|
a.timezone, a.visibility, a.total_distance, a.total_duration_seconds, a.elevation_gain, a.elevation_loss,
|
||||||
a.simplified_track, a.track_points_json, a.created_at, a.updated_at,
|
a.simplified_track, a.track_points_json, a.created_at, a.updated_at,
|
||||||
|
|
@ -377,6 +381,7 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
|
||||||
""", nativeQuery = true)
|
""", nativeQuery = true)
|
||||||
Page<Object[]> searchPublicTimeline(@Param("visibility") String visibility,
|
Page<Object[]> searchPublicTimeline(@Param("visibility") String visibility,
|
||||||
@Param("searchText") String searchText,
|
@Param("searchText") String searchText,
|
||||||
|
@Param("hashtagPattern") String hashtagPattern,
|
||||||
@Param("currentUserId") UUID currentUserId,
|
@Param("currentUserId") UUID currentUserId,
|
||||||
Pageable pageable);
|
Pageable pageable);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,44 @@ public class JwtTokenProvider {
|
||||||
private final SecretKey secretKey;
|
private final SecretKey secretKey;
|
||||||
private final long validityInMilliseconds;
|
private final long validityInMilliseconds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Known insecure placeholder values that may have leaked into deployment configs.
|
||||||
|
* The bean refuses to initialize if the configured secret matches any of them.
|
||||||
|
*/
|
||||||
|
private static final java.util.Set<String> KNOWN_PLACEHOLDERS = java.util.Set.of(
|
||||||
|
"change-this-secret-key-in-production-must-be-at-least-32-characters-long",
|
||||||
|
"changeme"
|
||||||
|
);
|
||||||
|
|
||||||
public JwtTokenProvider(
|
public JwtTokenProvider(
|
||||||
@Value("${fitpub.security.jwt.secret}") String secret,
|
@Value("${fitpub.security.jwt.secret:}") String secret,
|
||||||
@Value("${fitpub.security.jwt.expiration:86400000}") long validityInMilliseconds
|
@Value("${fitpub.security.jwt.expiration:86400000}") long validityInMilliseconds
|
||||||
) {
|
) {
|
||||||
|
if (secret == null || secret.isBlank()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"JWT secret is not configured. Set the JWT_SECRET environment variable to a random value of at least 32 characters."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (KNOWN_PLACEHOLDERS.contains(secret)) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"JWT secret is set to a known placeholder value. Generate a real secret (e.g. `openssl rand -base64 48`) and set JWT_SECRET."
|
||||||
|
);
|
||||||
|
}
|
||||||
// Ensure secret is long enough for HS256 (at least 256 bits / 32 bytes)
|
// Ensure secret is long enough for HS256 (at least 256 bits / 32 bytes)
|
||||||
if (secret.getBytes(StandardCharsets.UTF_8).length < 32) {
|
if (secret.getBytes(StandardCharsets.UTF_8).length < 32) {
|
||||||
throw new IllegalArgumentException("JWT secret must be at least 32 characters long");
|
throw new IllegalStateException(
|
||||||
|
"JWT secret must be at least 32 bytes long (HS256 requirement). Current length: "
|
||||||
|
+ secret.getBytes(StandardCharsets.UTF_8).length
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||||
this.validityInMilliseconds = validityInMilliseconds;
|
this.validityInMilliseconds = validityInMilliseconds;
|
||||||
|
// Log a fingerprint, never the secret itself
|
||||||
|
log.info("JWT signing key initialised (length={} bytes, fingerprint={}…{})",
|
||||||
|
secret.getBytes(StandardCharsets.UTF_8).length,
|
||||||
|
secret.substring(0, Math.min(4, secret.length())),
|
||||||
|
secret.substring(Math.max(0, secret.length() - 4))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -216,18 +216,27 @@ public class TimelineService {
|
||||||
public Page<TimelineActivityDTO> searchPublicTimeline(
|
public Page<TimelineActivityDTO> searchPublicTimeline(
|
||||||
UUID userId,
|
UUID userId,
|
||||||
String searchText,
|
String searchText,
|
||||||
|
String hashtag,
|
||||||
Pageable pageable
|
Pageable pageable
|
||||||
) {
|
) {
|
||||||
log.debug("Searching public timeline (userId: {}, search: {})",
|
log.debug("Searching public timeline (userId: {}, search: {}, hashtag: {})",
|
||||||
userId, searchText);
|
userId, searchText, hashtag);
|
||||||
|
|
||||||
// Create unsorted Pageable since ORDER BY is already in the native query
|
// Create unsorted Pageable since ORDER BY is already in the native query
|
||||||
Pageable unsortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize());
|
Pageable unsortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize());
|
||||||
|
|
||||||
|
// Build a POSIX regex matching #hashtag as a standalone token (case-insensitive via ~*).
|
||||||
|
// The hashtag value contains only \w characters (extraction enforces this), so no escaping needed.
|
||||||
|
String hashtagPattern = null;
|
||||||
|
if (hashtag != null && !hashtag.isBlank()) {
|
||||||
|
hashtagPattern = "(^|[^[:alnum:]_])#" + hashtag + "([^[:alnum:]_]|$)";
|
||||||
|
}
|
||||||
|
|
||||||
// Use optimized search query with JOINs and WHERE conditions
|
// Use optimized search query with JOINs and WHERE conditions
|
||||||
Page<Object[]> results = activityRepository.searchPublicTimeline(
|
Page<Object[]> results = activityRepository.searchPublicTimeline(
|
||||||
Activity.Visibility.PUBLIC.name(),
|
Activity.Visibility.PUBLIC.name(),
|
||||||
searchText,
|
searchText,
|
||||||
|
hashtagPattern,
|
||||||
userId,
|
userId,
|
||||||
unsortedPageable
|
unsortedPageable
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,8 @@ public class GpxFileValidator {
|
||||||
*/
|
*/
|
||||||
private void validateGpxStructure(byte[] fileData) {
|
private void validateGpxStructure(byte[] fileData) {
|
||||||
try {
|
try {
|
||||||
// Parse XML to check well-formedness
|
// Parse XML to check well-formedness, using a hardened factory (defends against XXE).
|
||||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
DocumentBuilderFactory factory = SecureXmlFactories.newDocumentBuilderFactory(true);
|
||||||
factory.setNamespaceAware(true);
|
|
||||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||||
Document doc = builder.parse(new ByteArrayInputStream(fileData));
|
Document doc = builder.parse(new ByteArrayInputStream(fileData));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,9 +59,8 @@ public class GpxParser {
|
||||||
ParsedActivityData parsedData = new ParsedActivityData();
|
ParsedActivityData parsedData = new ParsedActivityData();
|
||||||
parsedData.setSourceFormat("GPX");
|
parsedData.setSourceFormat("GPX");
|
||||||
|
|
||||||
// Parse XML
|
// Parse XML using a hardened factory (defends against XXE).
|
||||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
DocumentBuilderFactory factory = SecureXmlFactories.newDocumentBuilderFactory(true);
|
||||||
factory.setNamespaceAware(true);
|
|
||||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||||
Document doc = builder.parse(new ByteArrayInputStream(fileData));
|
Document doc = builder.parse(new ByteArrayInputStream(fileData));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
package net.javahippie.fitpub.util;
|
||||||
|
|
||||||
|
import javax.xml.XMLConstants;
|
||||||
|
import javax.xml.parsers.DocumentBuilderFactory;
|
||||||
|
import javax.xml.parsers.ParserConfigurationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared factory helpers that produce XML parsers hardened against XXE
|
||||||
|
* (XML External Entity) attacks. All XML parsing in the application that
|
||||||
|
* touches user-controlled bytes (e.g. uploaded GPX files) MUST go through
|
||||||
|
* one of these helpers rather than calling {@link DocumentBuilderFactory#newInstance()}
|
||||||
|
* directly.
|
||||||
|
*
|
||||||
|
* <p>The hardening applied here disables DTDs, external entities, parameter
|
||||||
|
* entities, and external DTD loading, and enables the JAXP secure-processing
|
||||||
|
* feature. Together these defeat the standard XXE payloads (file disclosure
|
||||||
|
* via {@code SYSTEM "file:///..."}, billion laughs, SSRF via external entities).
|
||||||
|
*/
|
||||||
|
public final class SecureXmlFactories {
|
||||||
|
|
||||||
|
private SecureXmlFactories() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a {@link DocumentBuilderFactory} hardened against XXE.
|
||||||
|
*
|
||||||
|
* @param namespaceAware whether to enable namespace awareness
|
||||||
|
* @return a hardened factory
|
||||||
|
*/
|
||||||
|
public static DocumentBuilderFactory newDocumentBuilderFactory(boolean namespaceAware) {
|
||||||
|
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||||
|
factory.setNamespaceAware(namespaceAware);
|
||||||
|
try {
|
||||||
|
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||||
|
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||||
|
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||||
|
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||||
|
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
|
||||||
|
} catch (ParserConfigurationException e) {
|
||||||
|
// Any underlying parser that does not support these features is
|
||||||
|
// unsafe — fail loudly rather than silently fall back.
|
||||||
|
throw new IllegalStateException("Failed to configure secure XML parser", e);
|
||||||
|
}
|
||||||
|
factory.setXIncludeAware(false);
|
||||||
|
factory.setExpandEntityReferences(false);
|
||||||
|
return factory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -72,7 +72,9 @@ fitpub:
|
||||||
# Security settings
|
# Security settings
|
||||||
security:
|
security:
|
||||||
jwt:
|
jwt:
|
||||||
secret: ${JWT_SECRET:change-this-secret-key-in-production-must-be-at-least-32-characters-long}
|
# JWT_SECRET must be set explicitly. The dev profile (application-dev.yml) provides a development default;
|
||||||
|
# production deployments without JWT_SECRET set will fail to start.
|
||||||
|
secret: ${JWT_SECRET:}
|
||||||
expiration: 86400000 # 24 hours in milliseconds
|
expiration: 86400000 # 24 hours in milliseconds
|
||||||
|
|
||||||
# Registration settings
|
# Registration settings
|
||||||
|
|
|
||||||
|
|
@ -1360,3 +1360,14 @@ h1 {
|
||||||
color: #000000;
|
color: #000000;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hashtag links inside titles, descriptions, and detail pages */
|
||||||
|
.hashtag-link {
|
||||||
|
color: #0d6efd;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashtag-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const FitPubTimeline = {
|
||||||
timelineType: 'public',
|
timelineType: 'public',
|
||||||
searchText: '',
|
searchText: '',
|
||||||
dateFilter: '',
|
dateFilter: '',
|
||||||
|
hashtagFilter: '',
|
||||||
searchDebounceTimer: null,
|
searchDebounceTimer: null,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -17,7 +18,45 @@ const FitPubTimeline = {
|
||||||
*/
|
*/
|
||||||
init: function(type) {
|
init: function(type) {
|
||||||
this.timelineType = type;
|
this.timelineType = type;
|
||||||
|
|
||||||
|
// Read hashtag filter from URL query string
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const hashtagParam = params.get('hashtag');
|
||||||
|
if (hashtagParam && /^\w+$/.test(hashtagParam)) {
|
||||||
|
this.hashtagFilter = hashtagParam;
|
||||||
|
}
|
||||||
|
|
||||||
this.setupSearchHandlers();
|
this.setupSearchHandlers();
|
||||||
|
this.renderHashtagFilterBadge();
|
||||||
|
this.loadTimeline(0);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show or hide the active hashtag filter badge
|
||||||
|
*/
|
||||||
|
renderHashtagFilterBadge: function() {
|
||||||
|
const badge = document.getElementById('hashtagFilterBadge');
|
||||||
|
if (!badge) return;
|
||||||
|
|
||||||
|
if (this.hashtagFilter) {
|
||||||
|
const label = badge.querySelector('#hashtagFilterLabel');
|
||||||
|
if (label) label.textContent = '#' + this.hashtagFilter;
|
||||||
|
badge.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
badge.classList.add('d-none');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the active hashtag filter
|
||||||
|
*/
|
||||||
|
clearHashtagFilter: function() {
|
||||||
|
this.hashtagFilter = '';
|
||||||
|
this.renderHashtagFilterBadge();
|
||||||
|
// Update URL without reload
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('hashtag');
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
this.loadTimeline(0);
|
this.loadTimeline(0);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -68,6 +107,10 @@ const FitPubTimeline = {
|
||||||
endpoint += `&search=${encodeURIComponent(this.searchText)}`;
|
endpoint += `&search=${encodeURIComponent(this.searchText)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.hashtagFilter) {
|
||||||
|
endpoint += `&hashtag=${encodeURIComponent(this.hashtagFilter)}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.dateFilter) {
|
if (this.dateFilter) {
|
||||||
// Only add if valid format
|
// Only add if valid format
|
||||||
const validation = this.validateDateFormat(this.dateFilter);
|
const validation = this.validateDateFormat(this.dateFilter);
|
||||||
|
|
@ -166,18 +209,14 @@ const FitPubTimeline = {
|
||||||
<!-- Activity Title and Description -->
|
<!-- Activity Title and Description -->
|
||||||
<h5 class="card-title">
|
<h5 class="card-title">
|
||||||
${activity.isLocal
|
${activity.isLocal
|
||||||
? `<a href="/activities/${activity.id}" class="text-decoration-none text-dark">
|
? this.renderTitleLinkWithHashtags(activity.title, `/activities/${activity.id}`, 'activity-title-link', '')
|
||||||
${this.escapeHtml(activity.title || 'Untitled Activity')}
|
: this.renderTitleLinkWithHashtags(activity.title, activity.activityUri || '#', 'activity-title-link', 'target="_blank"')
|
||||||
</a>`
|
+ (activity.isLocal ? '' : ' <i class="bi bi-box-arrow-up-right ms-1 small"></i>')
|
||||||
: `<a href="${activity.activityUri || '#'}" target="_blank" class="text-decoration-none text-dark">
|
|
||||||
${this.escapeHtml(activity.title || 'Untitled Activity')}
|
|
||||||
<i class="bi bi-box-arrow-up-right ms-1 small"></i>
|
|
||||||
</a>`
|
|
||||||
}
|
}
|
||||||
</h5>
|
</h5>
|
||||||
|
|
||||||
${activity.description
|
${activity.description
|
||||||
? `<p class="card-text">${this.escapeHtml(activity.description).substring(0, 200)}${activity.description.length > 200 ? '...' : ''}</p>`
|
? `<p class="card-text">${this.linkifyHashtags(activity.description.length > 200 ? activity.description.substring(0, 200) + '...' : activity.description)}</p>`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -562,6 +601,58 @@ const FitPubTimeline = {
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape text for safe HTML insertion AND turn #hashtags into links
|
||||||
|
* pointing to the public timeline filtered by that hashtag.
|
||||||
|
* @param {string} text - Text to process
|
||||||
|
* @returns {string} HTML-safe string with hashtag anchors
|
||||||
|
*/
|
||||||
|
linkifyHashtags: function(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const escaped = this.escapeHtml(text);
|
||||||
|
return escaped.replace(/(^|\s)#(\w+)/g, (match, lead, tag) =>
|
||||||
|
`${lead}<a href="/timeline?hashtag=${encodeURIComponent(tag.toLowerCase())}" class="hashtag-link">#${tag}</a>`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a title that links to an activity, with embedded #hashtags
|
||||||
|
* linking instead to the public timeline filtered by that tag.
|
||||||
|
* Avoids invalid nested <a> tags by rendering segments as siblings.
|
||||||
|
* @param {string} text - Title text
|
||||||
|
* @param {string} activityHref - Link target for non-hashtag portions
|
||||||
|
* @param {string} extraClass - Extra CSS classes for the activity link segments
|
||||||
|
* @param {string} extraAttrs - Extra HTML attributes for the activity link segments
|
||||||
|
* @returns {string} HTML
|
||||||
|
*/
|
||||||
|
renderTitleLinkWithHashtags: function(text, activityHref, extraClass, extraAttrs) {
|
||||||
|
const safeText = text || 'Untitled Activity';
|
||||||
|
extraClass = extraClass || '';
|
||||||
|
extraAttrs = extraAttrs || '';
|
||||||
|
const wrapActivity = (chunk) =>
|
||||||
|
chunk
|
||||||
|
? `<a href="${activityHref}" class="text-decoration-none text-dark ${extraClass}" ${extraAttrs}>${chunk}</a>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
const regex = /(^|\s)#(\w+)/g;
|
||||||
|
let last = 0;
|
||||||
|
let m;
|
||||||
|
while ((m = regex.exec(safeText)) !== null) {
|
||||||
|
// Text before the hashtag (and the leading whitespace) goes to activity
|
||||||
|
const before = safeText.substring(last, m.index) + m[1];
|
||||||
|
if (before) parts.push(wrapActivity(this.escapeHtml(before)));
|
||||||
|
const tag = m[2];
|
||||||
|
parts.push(
|
||||||
|
`<a href="/timeline?hashtag=${encodeURIComponent(tag.toLowerCase())}" class="hashtag-link">#${this.escapeHtml(tag)}</a>`
|
||||||
|
);
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
const tail = safeText.substring(last);
|
||||||
|
if (tail) parts.push(wrapActivity(this.escapeHtml(tail)));
|
||||||
|
return parts.join('');
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render indoor activity placeholder with emoji
|
* Render indoor activity placeholder with emoji
|
||||||
* @param {HTMLElement} element - Container element
|
* @param {HTMLElement} element - Container element
|
||||||
|
|
|
||||||
|
|
@ -519,7 +519,7 @@
|
||||||
|
|
||||||
function renderActivity(activity) {
|
function renderActivity(activity) {
|
||||||
// Header
|
// Header
|
||||||
document.getElementById('activityTitle').textContent = activity.title || 'Untitled Activity';
|
document.getElementById('activityTitle').innerHTML = linkifyHashtags(activity.title || 'Untitled Activity');
|
||||||
document.getElementById('activityType').textContent = activity.activityType;
|
document.getElementById('activityType').textContent = activity.activityType;
|
||||||
document.getElementById('activityType').className = `activity-type-badge activity-type-${activity.activityType.toLowerCase()}`;
|
document.getElementById('activityType').className = `activity-type-badge activity-type-${activity.activityType.toLowerCase()}`;
|
||||||
// Format date with timezone awareness
|
// Format date with timezone awareness
|
||||||
|
|
@ -570,7 +570,7 @@
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
if (activity.description) {
|
if (activity.description) {
|
||||||
document.getElementById('activityDescription').textContent = activity.description;
|
document.getElementById('activityDescription').innerHTML = linkifyHashtags(activity.description);
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('activityDescription').style.display = 'none';
|
document.getElementById('activityDescription').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
@ -1721,6 +1721,14 @@
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function linkifyHashtags(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const escaped = escapeHtml(text);
|
||||||
|
return escaped.replace(/(^|\s)#(\w+)/g, (match, lead, tag) =>
|
||||||
|
`${lead}<a href="/timeline?hashtag=${encodeURIComponent(tag.toLowerCase())}" class="hashtag-link">#${tag}</a>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Delete functionality
|
// Delete functionality
|
||||||
document.getElementById('deleteBtn').addEventListener('click', function() {
|
document.getElementById('deleteBtn').addEventListener('click', function() {
|
||||||
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||||
|
|
|
||||||
|
|
@ -152,9 +152,7 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<h5 class="card-title">
|
<h5 class="card-title">
|
||||||
<a href="/activities/${activity.id}" class="text-decoration-none">
|
${renderTitleLinkWithHashtags(activity.title, `/activities/${activity.id}`)}
|
||||||
${escapeHtml(activity.title || 'Untitled Activity')}
|
|
||||||
</a>
|
|
||||||
</h5>
|
</h5>
|
||||||
<p class="text-muted mb-2">
|
<p class="text-muted mb-2">
|
||||||
<span class="activity-type-badge activity-type-${activity.activityType.toLowerCase()}${activity.race ? ' race-activity' : ''}">
|
<span class="activity-type-badge activity-type-${activity.activityType.toLowerCase()}${activity.race ? ' race-activity' : ''}">
|
||||||
|
|
@ -181,7 +179,7 @@
|
||||||
${activity.visibility}
|
${activity.visibility}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
${activity.description ? `<p class="card-text">${escapeHtml(activity.description).substring(0, 150)}${activity.description.length > 150 ? '...' : ''}</p>` : ''}
|
${activity.description ? `<p class="card-text">${linkifyHashtags(activity.description.length > 150 ? activity.description.substring(0, 150) + '...' : activity.description)}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
|
|
@ -332,6 +330,35 @@
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function linkifyHashtags(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const escaped = escapeHtml(text);
|
||||||
|
return escaped.replace(/(^|\s)#(\w+)/g, (match, lead, tag) =>
|
||||||
|
`${lead}<a href="/timeline?hashtag=${encodeURIComponent(tag.toLowerCase())}" class="hashtag-link">#${tag}</a>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTitleLinkWithHashtags(text, activityHref) {
|
||||||
|
const safeText = text || 'Untitled Activity';
|
||||||
|
const wrap = (chunk) => chunk
|
||||||
|
? `<a href="${activityHref}" class="text-decoration-none activity-title-link">${chunk}</a>`
|
||||||
|
: '';
|
||||||
|
const parts = [];
|
||||||
|
const regex = /(^|\s)#(\w+)/g;
|
||||||
|
let last = 0;
|
||||||
|
let m;
|
||||||
|
while ((m = regex.exec(safeText)) !== null) {
|
||||||
|
const before = safeText.substring(last, m.index) + m[1];
|
||||||
|
if (before) parts.push(wrap(escapeHtml(before)));
|
||||||
|
const tag = m[2];
|
||||||
|
parts.push(`<a href="/timeline?hashtag=${encodeURIComponent(tag.toLowerCase())}" class="hashtag-link">#${escapeHtml(tag)}</a>`);
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
const tail = safeText.substring(last);
|
||||||
|
if (tail) parts.push(wrap(escapeHtml(tail)));
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,18 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Active hashtag filter -->
|
||||||
|
<div id="hashtagFilterBadge" class="alert alert-info d-flex justify-content-between align-items-center d-none" role="alert">
|
||||||
|
<span>
|
||||||
|
<i class="bi bi-hash"></i>
|
||||||
|
Filtering by <strong id="hashtagFilterLabel">#tag</strong>
|
||||||
|
</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="FitPubTimeline.clearHashtagFilter(); return false;">
|
||||||
|
<i class="bi bi-x-lg"></i> Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Loading Indicator -->
|
<!-- Loading Indicator -->
|
||||||
<div id="loadingIndicator" class="text-center py-5">
|
<div id="loadingIndicator" class="text-center py-5">
|
||||||
<div class="spinner-border text-primary" role="status">
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue