tangled
alpha
login
or
join now
serendipty01.dev
/
smokesignal
forked from
smokesignal.events/smokesignal
0
fork
atom
The smokesignal.events web application
0
fork
atom
overview
issues
pulls
pipelines
feature: event facets
Nick Gerakines
4 months ago
53cc4130
7da344fc
+1016
-10
10 changed files
expand all
collapse all
unified
split
Cargo.toml
src
config.rs
facets.rs
http
event_view.rs
handle_create_event.rs
handle_edit_event.rs
handle_profile.rs
handle_view_event.rs
lib.rs
task_search_indexer.rs
+1
Cargo.toml
···
58
58
reqwest-chain = "1"
59
59
reqwest-middleware = { version = "0.4", features = ["http2", "json", "multipart"] }
60
60
reqwest-retry = "0.7"
61
61
+
regex = "1"
61
62
duration-str = "0.11"
62
63
minijinja = { version = "2.7", features = ["builtins", "json", "urlencode"] }
63
64
minijinja-autoreload = { version = "2.7", optional = true }
+22
src/config.rs
···
65
65
pub enable_opensearch: bool,
66
66
pub enable_task_opensearch: bool,
67
67
pub opensearch_endpoint: Option<String>,
68
68
+
pub facets_mentions_max: usize,
69
69
+
pub facets_tags_max: usize,
70
70
+
pub facets_links_max: usize,
71
71
+
pub facets_max: usize,
68
72
}
69
73
70
74
impl Config {
···
156
160
}
157
161
};
158
162
163
163
+
// Parse facet limit configuration
164
164
+
let facets_mentions_max = default_env("FACETS_MENTIONS_MAX", "5")
165
165
+
.parse::<usize>()
166
166
+
.unwrap_or(5);
167
167
+
let facets_tags_max = default_env("FACETS_TAGS_MAX", "5")
168
168
+
.parse::<usize>()
169
169
+
.unwrap_or(5);
170
170
+
let facets_links_max = default_env("FACETS_LINKS_MAX", "5")
171
171
+
.parse::<usize>()
172
172
+
.unwrap_or(5);
173
173
+
let facets_max = default_env("FACETS_MAX", "10")
174
174
+
.parse::<usize>()
175
175
+
.unwrap_or(10);
176
176
+
159
177
Ok(Self {
160
178
version: version()?,
161
179
http_port,
···
181
199
enable_opensearch,
182
200
enable_task_opensearch,
183
201
opensearch_endpoint,
202
202
+
facets_mentions_max,
203
203
+
facets_tags_max,
204
204
+
facets_links_max,
205
205
+
facets_max,
184
206
})
185
207
}
186
208
+896
src/facets.rs
···
1
1
+
//! Rich text facet structures and rendering for AT Protocol.
2
2
+
//!
3
3
+
//! This module provides structures for handling rich text facets (mentions, links, hashtags),
4
4
+
//! parsing them from text, and rendering them as HTML for display in the UI.
5
5
+
//!
6
6
+
//! # Byte Offset Calculation
7
7
+
//!
8
8
+
//! This implementation correctly uses UTF-8 byte offsets as required by AT Protocol.
9
9
+
//! The facets use "inclusive start and exclusive end" byte ranges. All parsing is done
10
10
+
//! using `regex::bytes::Regex` which operates on byte slices and returns byte positions,
11
11
+
//! ensuring correct handling of multi-byte UTF-8 characters (emojis, CJK, accented chars).
12
12
+
13
13
+
use atproto_identity::resolve::IdentityResolver;
14
14
+
use atproto_record::lexicon::app::bsky::richtext::facet::{Facet, FacetFeature, ByteSlice, Mention, Link, Tag};
15
15
+
use regex::bytes::Regex;
16
16
+
use std::fmt::Write;
17
17
+
18
18
+
/// Configuration for facet parsing and rendering limits
19
19
+
#[derive(Debug, Clone, Copy)]
20
20
+
pub struct FacetLimits {
21
21
+
/// Maximum number of mention facets to process (default: 5)
22
22
+
pub mentions_max: usize,
23
23
+
/// Maximum number of tag facets to process (default: 5)
24
24
+
pub tags_max: usize,
25
25
+
/// Maximum number of link facets to process (default: 5)
26
26
+
pub links_max: usize,
27
27
+
/// Maximum total number of facets to process (default: 10)
28
28
+
pub max: usize,
29
29
+
}
30
30
+
31
31
+
impl Default for FacetLimits {
32
32
+
fn default() -> Self {
33
33
+
Self {
34
34
+
mentions_max: 5,
35
35
+
tags_max: 5,
36
36
+
links_max: 5,
37
37
+
max: 10,
38
38
+
}
39
39
+
}
40
40
+
}
41
41
+
42
42
+
/// Mention span with byte positions and handle
43
43
+
#[derive(Debug)]
44
44
+
pub struct MentionSpan {
45
45
+
pub start: usize,
46
46
+
pub end: usize,
47
47
+
pub handle: String,
48
48
+
}
49
49
+
50
50
+
/// URL span with byte positions and URL
51
51
+
#[derive(Debug)]
52
52
+
pub struct UrlSpan {
53
53
+
pub start: usize,
54
54
+
pub end: usize,
55
55
+
pub url: String,
56
56
+
}
57
57
+
58
58
+
/// Tag span with byte positions and tag text
59
59
+
#[derive(Debug)]
60
60
+
pub struct TagSpan {
61
61
+
pub start: usize,
62
62
+
pub end: usize,
63
63
+
pub tag: String,
64
64
+
}
65
65
+
66
66
+
/// Parse mentions from text and return their byte positions
67
67
+
/// This function excludes mentions that appear within URLs
68
68
+
pub fn parse_mentions(text: &str) -> Vec<MentionSpan> {
69
69
+
let mut spans = Vec::new();
70
70
+
71
71
+
// First, parse all URLs to exclude mention matches within them
72
72
+
let url_spans = parse_urls(text);
73
73
+
74
74
+
// Regex based on: https://atproto.com/specs/handle#handle-identifier-syntax
75
75
+
// Pattern: [$|\W](@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)
76
76
+
let mention_regex = Regex::new(
77
77
+
r"(?:^|[^\w])(@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)"
78
78
+
).unwrap();
79
79
+
80
80
+
let text_bytes = text.as_bytes();
81
81
+
for capture in mention_regex.captures_iter(text_bytes) {
82
82
+
if let Some(mention_match) = capture.get(1) {
83
83
+
let start = mention_match.start();
84
84
+
let end = mention_match.end();
85
85
+
86
86
+
// Check if this mention overlaps with any URL
87
87
+
let overlaps_url = url_spans.iter().any(|url| {
88
88
+
// Check if mention is within or overlaps the URL span
89
89
+
(start >= url.start && start < url.end) || (end > url.start && end <= url.end)
90
90
+
});
91
91
+
92
92
+
// Only add the mention if it doesn't overlap with a URL
93
93
+
if !overlaps_url {
94
94
+
let handle = std::str::from_utf8(&mention_match.as_bytes()[1..])
95
95
+
.unwrap_or_default()
96
96
+
.to_string();
97
97
+
98
98
+
spans.push(MentionSpan { start, end, handle });
99
99
+
}
100
100
+
}
101
101
+
}
102
102
+
103
103
+
spans
104
104
+
}
105
105
+
106
106
+
/// Parse URLs from text and return their byte positions
107
107
+
pub fn parse_urls(text: &str) -> Vec<UrlSpan> {
108
108
+
let mut spans = Vec::new();
109
109
+
110
110
+
// Partial/naive URL regex based on: https://stackoverflow.com/a/3809435
111
111
+
// Pattern: [$|\W](https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*[-a-zA-Z0-9@%_\+~#//=])?)
112
112
+
let url_regex = Regex::new(
113
113
+
r"(?:^|[^\w])(https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*[-a-zA-Z0-9@%_\+~#//=])?)"
114
114
+
).unwrap();
115
115
+
116
116
+
let text_bytes = text.as_bytes();
117
117
+
for capture in url_regex.captures_iter(text_bytes) {
118
118
+
if let Some(url_match) = capture.get(1) {
119
119
+
let url = std::str::from_utf8(url_match.as_bytes())
120
120
+
.unwrap_or_default()
121
121
+
.to_string();
122
122
+
123
123
+
spans.push(UrlSpan {
124
124
+
start: url_match.start(),
125
125
+
end: url_match.end(),
126
126
+
url,
127
127
+
});
128
128
+
}
129
129
+
}
130
130
+
131
131
+
spans
132
132
+
}
133
133
+
134
134
+
/// Parse hashtags from text and return their byte positions
135
135
+
pub fn parse_tags(text: &str) -> Vec<TagSpan> {
136
136
+
let mut spans = Vec::new();
137
137
+
138
138
+
// Regex based on: https://github.com/bluesky-social/atproto/blob/d91988fe79030b61b556dd6f16a46f0c3b9d0b44/packages/api/src/rich-text/util.ts
139
139
+
// Simplified for Rust - matches hashtags at word boundaries
140
140
+
// Pattern matches: start of string or non-word char, then # or #, then tag content
141
141
+
let tag_regex = Regex::new(r"(?:^|[^\w])([##])([\w]+(?:[\w]*)*)").unwrap();
142
142
+
143
143
+
let text_bytes = text.as_bytes();
144
144
+
145
145
+
// Work with bytes for proper position tracking
146
146
+
for capture in tag_regex.captures_iter(text_bytes) {
147
147
+
if let (Some(full_match), Some(hash_match), Some(tag_match)) =
148
148
+
(capture.get(0), capture.get(1), capture.get(2))
149
149
+
{
150
150
+
// Calculate the absolute byte position of the hash symbol
151
151
+
// The full match includes the preceding character (if any)
152
152
+
// so we need to adjust for that
153
153
+
let match_start = full_match.start();
154
154
+
let hash_offset = hash_match.start() - full_match.start();
155
155
+
let start = match_start + hash_offset;
156
156
+
let end = match_start + hash_offset + hash_match.len() + tag_match.len();
157
157
+
158
158
+
// Extract just the tag text (without the hash symbol)
159
159
+
// Normalize to lowercase for case-insensitive tag matching
160
160
+
let tag = std::str::from_utf8(tag_match.as_bytes())
161
161
+
.unwrap_or_default()
162
162
+
.to_lowercase();
163
163
+
164
164
+
// Only include tags that are not purely numeric
165
165
+
if !tag.chars().all(|c| c.is_ascii_digit()) {
166
166
+
spans.push(TagSpan { start, end, tag });
167
167
+
}
168
168
+
}
169
169
+
}
170
170
+
171
171
+
spans
172
172
+
}
173
173
+
174
174
+
/// Parse facets from text and return a vector of Facet objects.
175
175
+
///
176
176
+
/// This function extracts mentions, URLs, and hashtags from the provided text
177
177
+
/// and creates AT Protocol facets with proper byte indices.
178
178
+
///
179
179
+
/// Mentions are resolved to actual DIDs using the provided identity resolver.
180
180
+
/// If a handle cannot be resolved to a DID, the mention facet is skipped.
181
181
+
///
182
182
+
/// # Arguments
183
183
+
/// * `text` - The text to extract facets from
184
184
+
/// * `identity_resolver` - Resolver for converting handles to DIDs
185
185
+
/// * `limits` - Configuration for maximum facets per type and total
186
186
+
///
187
187
+
/// # Returns
188
188
+
/// Optional vector of facets. Returns None if no facets were found.
189
189
+
pub async fn parse_facets_from_text(
190
190
+
text: &str,
191
191
+
identity_resolver: &dyn IdentityResolver,
192
192
+
limits: &FacetLimits,
193
193
+
) -> Option<Vec<Facet>> {
194
194
+
let mut facets = Vec::new();
195
195
+
196
196
+
// Parse mentions (limited by mentions_max)
197
197
+
let mention_spans = parse_mentions(text);
198
198
+
let mut mention_count = 0;
199
199
+
for mention in mention_spans {
200
200
+
if mention_count >= limits.mentions_max {
201
201
+
break;
202
202
+
}
203
203
+
204
204
+
// Try to resolve the handle to a DID
205
205
+
// First try with at:// prefix, then without
206
206
+
let at_uri = format!("at://{}", mention.handle);
207
207
+
let did_result = match identity_resolver.resolve(&at_uri).await {
208
208
+
Ok(doc) => Ok(doc),
209
209
+
Err(_) => identity_resolver.resolve(&mention.handle).await,
210
210
+
};
211
211
+
212
212
+
// Only add the mention facet if we successfully resolved the DID
213
213
+
if let Ok(did_doc) = did_result {
214
214
+
facets.push(Facet {
215
215
+
index: ByteSlice {
216
216
+
byte_start: mention.start,
217
217
+
byte_end: mention.end,
218
218
+
},
219
219
+
features: vec![FacetFeature::Mention(Mention {
220
220
+
did: did_doc.id.to_string(),
221
221
+
})],
222
222
+
});
223
223
+
mention_count += 1;
224
224
+
}
225
225
+
// If resolution fails, we skip this mention facet entirely
226
226
+
}
227
227
+
228
228
+
// Parse URLs (limited by links_max)
229
229
+
let url_spans = parse_urls(text);
230
230
+
for (idx, url) in url_spans.into_iter().enumerate() {
231
231
+
if idx >= limits.links_max {
232
232
+
break;
233
233
+
}
234
234
+
facets.push(Facet {
235
235
+
index: ByteSlice {
236
236
+
byte_start: url.start,
237
237
+
byte_end: url.end,
238
238
+
},
239
239
+
features: vec![FacetFeature::Link(Link { uri: url.url })],
240
240
+
});
241
241
+
}
242
242
+
243
243
+
// Parse hashtags (limited by tags_max)
244
244
+
let tag_spans = parse_tags(text);
245
245
+
for (idx, tag_span) in tag_spans.into_iter().enumerate() {
246
246
+
if idx >= limits.tags_max {
247
247
+
break;
248
248
+
}
249
249
+
facets.push(Facet {
250
250
+
index: ByteSlice {
251
251
+
byte_start: tag_span.start,
252
252
+
byte_end: tag_span.end,
253
253
+
},
254
254
+
features: vec![FacetFeature::Tag(Tag { tag: tag_span.tag })],
255
255
+
});
256
256
+
}
257
257
+
258
258
+
// Apply global facet limit (truncate if exceeds max)
259
259
+
if facets.len() > limits.max {
260
260
+
facets.truncate(limits.max);
261
261
+
}
262
262
+
263
263
+
// Only return facets if we found any
264
264
+
if !facets.is_empty() {
265
265
+
Some(facets)
266
266
+
} else {
267
267
+
None
268
268
+
}
269
269
+
}
270
270
+
271
271
+
/// HTML escape helper function
272
272
+
fn html_escape(text: &str) -> String {
273
273
+
text.chars()
274
274
+
.map(|c| match c {
275
275
+
'&' => "&".to_string(),
276
276
+
'<' => "<".to_string(),
277
277
+
'>' => ">".to_string(),
278
278
+
'"' => """.to_string(),
279
279
+
'\'' => "'".to_string(),
280
280
+
c => c.to_string(),
281
281
+
})
282
282
+
.collect()
283
283
+
}
284
284
+
285
285
+
/// Check if text contains HTML tags
286
286
+
/// This is used to detect potentially malicious content
287
287
+
fn contains_html_tags(text: &str) -> bool {
288
288
+
// Look for patterns that indicate HTML tags
289
289
+
// We're looking for < followed by either a letter, /, or !
290
290
+
let mut chars = text.chars().peekable();
291
291
+
while let Some(ch) = chars.next() {
292
292
+
if ch == '<'
293
293
+
&& let Some(&next_ch) = chars.peek()
294
294
+
{
295
295
+
// Check if this looks like an HTML tag
296
296
+
if next_ch.is_ascii_alphabetic() || next_ch == '/' || next_ch == '!' {
297
297
+
return true;
298
298
+
}
299
299
+
}
300
300
+
}
301
301
+
false
302
302
+
}
303
303
+
304
304
+
/// Render text with facets as HTML.
305
305
+
///
306
306
+
/// This function converts plain text with facet annotations into HTML with proper
307
307
+
/// links for mentions, URLs, and hashtags based on the facet information.
308
308
+
///
309
309
+
/// # HTML Output
310
310
+
/// - Mentions: `<a href="/u/[did]">@handle</a>`
311
311
+
/// - Links: `<a href="[url]" target="_blank" rel="noopener noreferrer">[url]</a>`
312
312
+
/// - Tags: `<a href="/t/[tag]">#tag</a>`
313
313
+
/// - Regular text is HTML-escaped for security
314
314
+
///
315
315
+
/// # Arguments
316
316
+
/// * `text` - The plain text content
317
317
+
/// * `facets` - Optional facets to apply to the text
318
318
+
/// * `limits` - Configuration for maximum facets per type and total
319
319
+
///
320
320
+
/// # Returns
321
321
+
/// HTML string with facets rendered as links
322
322
+
pub fn render_text_with_facets_html(
323
323
+
text: &str,
324
324
+
facets: Option<&Vec<Facet>>,
325
325
+
limits: &FacetLimits,
326
326
+
) -> String {
327
327
+
// First, check if the text contains HTML tags
328
328
+
// If it does, treat it as suspicious and just clean it without applying facets
329
329
+
if contains_html_tags(text) {
330
330
+
// Use ammonia to strip ALL HTML and return plain text
331
331
+
let cleaned = ammonia::clean(text);
332
332
+
// Convert newlines to <br> tags after cleaning
333
333
+
return cleaned.replace('\n', "<br>");
334
334
+
}
335
335
+
336
336
+
let text_bytes = text.as_bytes();
337
337
+
338
338
+
// If no facets, just return escaped text
339
339
+
let Some(facets) = facets else {
340
340
+
return html_escape(text);
341
341
+
};
342
342
+
343
343
+
// Sort facets by start position to process them in order
344
344
+
let mut sorted_facets: Vec<_> = facets.iter().collect();
345
345
+
sorted_facets.sort_by_key(|f| f.index.byte_start);
346
346
+
347
347
+
// Apply limits: count facets by type and limit total
348
348
+
let mut mention_count = 0;
349
349
+
let mut link_count = 0;
350
350
+
let mut tag_count = 0;
351
351
+
let mut total_count = 0;
352
352
+
353
353
+
let filtered_facets: Vec<_> = sorted_facets
354
354
+
.into_iter()
355
355
+
.filter(|facet| {
356
356
+
if total_count >= limits.max {
357
357
+
return false;
358
358
+
}
359
359
+
360
360
+
// Check facet type and apply per-type limits
361
361
+
let should_include = facet.features.first().map_or(false, |feature| {
362
362
+
match feature {
363
363
+
FacetFeature::Mention(_) if mention_count < limits.mentions_max => {
364
364
+
mention_count += 1;
365
365
+
true
366
366
+
}
367
367
+
FacetFeature::Link(_) if link_count < limits.links_max => {
368
368
+
link_count += 1;
369
369
+
true
370
370
+
}
371
371
+
FacetFeature::Tag(_) if tag_count < limits.tags_max => {
372
372
+
tag_count += 1;
373
373
+
true
374
374
+
}
375
375
+
_ => false,
376
376
+
}
377
377
+
});
378
378
+
379
379
+
if should_include {
380
380
+
total_count += 1;
381
381
+
}
382
382
+
383
383
+
should_include
384
384
+
})
385
385
+
.collect();
386
386
+
387
387
+
let mut html = String::new();
388
388
+
let mut last_end = 0;
389
389
+
390
390
+
for facet in filtered_facets {
391
391
+
// Add any text before this facet (HTML-escaped)
392
392
+
if facet.index.byte_start > last_end {
393
393
+
let text_before = std::str::from_utf8(&text_bytes[last_end..facet.index.byte_start])
394
394
+
.unwrap_or("");
395
395
+
html.push_str(&html_escape(text_before));
396
396
+
}
397
397
+
398
398
+
// Get the text covered by this facet
399
399
+
let facet_text =
400
400
+
std::str::from_utf8(&text_bytes[facet.index.byte_start..facet.index.byte_end])
401
401
+
.unwrap_or("");
402
402
+
403
403
+
// Process the facet based on its feature type
404
404
+
// Only process the first feature (in practice, there should only be one per facet)
405
405
+
if let Some(feature) = facet.features.first() {
406
406
+
match feature {
407
407
+
FacetFeature::Mention(mention) => {
408
408
+
write!(
409
409
+
&mut html,
410
410
+
r#"<a href="/u/{}">{}</a>"#,
411
411
+
html_escape(&mention.did),
412
412
+
html_escape(facet_text)
413
413
+
)
414
414
+
.unwrap();
415
415
+
}
416
416
+
FacetFeature::Link(link) => {
417
417
+
// Only create link tags for safe URLs
418
418
+
if link.uri.starts_with("http://")
419
419
+
|| link.uri.starts_with("https://")
420
420
+
|| link.uri.starts_with("/")
421
421
+
{
422
422
+
write!(
423
423
+
&mut html,
424
424
+
r#"<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>"#,
425
425
+
html_escape(&link.uri),
426
426
+
html_escape(facet_text)
427
427
+
)
428
428
+
.unwrap();
429
429
+
} else {
430
430
+
// For unsafe URLs (like javascript:), just render as plain text
431
431
+
html.push_str(&html_escape(facet_text));
432
432
+
}
433
433
+
}
434
434
+
FacetFeature::Tag(tag) => {
435
435
+
// URL-encode the tag for the href attribute
436
436
+
let encoded_tag = urlencoding::encode(&tag.tag);
437
437
+
write!(
438
438
+
&mut html,
439
439
+
r#"<a href="/t/{}">{}</a>"#,
440
440
+
encoded_tag,
441
441
+
html_escape(facet_text)
442
442
+
)
443
443
+
.unwrap();
444
444
+
}
445
445
+
}
446
446
+
}
447
447
+
448
448
+
last_end = facet.index.byte_end;
449
449
+
}
450
450
+
451
451
+
// Add any remaining text after the last facet
452
452
+
if last_end < text_bytes.len() {
453
453
+
let remaining_text = std::str::from_utf8(&text_bytes[last_end..]).unwrap_or("");
454
454
+
html.push_str(&html_escape(remaining_text));
455
455
+
}
456
456
+
457
457
+
// Sanitize the final HTML output to ensure safety
458
458
+
// Configure ammonia to only allow <a> tags with specific attributes
459
459
+
let mut builder = ammonia::Builder::new();
460
460
+
builder
461
461
+
.tags(std::collections::HashSet::from(["a", "br"]))
462
462
+
// Don't automatically add rel="nofollow" - we'll handle it in the attribute filter
463
463
+
.link_rel(None)
464
464
+
// Allow relative URLs (for internal links like /u/... and /t/...)
465
465
+
.url_relative(ammonia::UrlRelative::PassThrough)
466
466
+
.attribute_filter(|element, attribute, value| match (element, attribute) {
467
467
+
("a", "href") => {
468
468
+
// Only allow safe URLs: relative paths starting with /, or http(s) URLs
469
469
+
if value.starts_with('/')
470
470
+
|| value.starts_with("http://")
471
471
+
|| value.starts_with("https://")
472
472
+
{
473
473
+
Some(value.into())
474
474
+
} else {
475
475
+
None
476
476
+
}
477
477
+
}
478
478
+
("a", "target") => {
479
479
+
if value == "_blank" {
480
480
+
Some(value.into())
481
481
+
} else {
482
482
+
None
483
483
+
}
484
484
+
}
485
485
+
("a", "rel") => {
486
486
+
// For external links, ensure nofollow is present
487
487
+
if value.contains("noopener") || value.contains("noreferrer") {
488
488
+
// Keep the existing rel value but add nofollow if not present
489
489
+
if !value.contains("nofollow") {
490
490
+
Some(format!("{} nofollow", value).into())
491
491
+
} else {
492
492
+
Some(value.into())
493
493
+
}
494
494
+
} else {
495
495
+
// Just nofollow for other cases
496
496
+
Some("nofollow".into())
497
497
+
}
498
498
+
}
499
499
+
("br", _) => None, // br tags don't have attributes
500
500
+
_ => None,
501
501
+
});
502
502
+
503
503
+
builder.clean(&html).to_string()
504
504
+
}
505
505
+
506
506
+
#[cfg(test)]
507
507
+
mod tests {
508
508
+
use atproto_identity::model::Document;
509
509
+
use atproto_record::lexicon::app::bsky::richtext::facet::{ByteSlice, Link, Mention, Tag};
510
510
+
use async_trait::async_trait;
511
511
+
use std::collections::HashMap;
512
512
+
513
513
+
use super::*;
514
514
+
515
515
+
/// Mock identity resolver for testing
516
516
+
struct MockIdentityResolver {
517
517
+
handles_to_dids: HashMap<String, String>,
518
518
+
}
519
519
+
520
520
+
impl MockIdentityResolver {
521
521
+
fn new() -> Self {
522
522
+
let mut handles_to_dids = HashMap::new();
523
523
+
handles_to_dids.insert(
524
524
+
"alice.bsky.social".to_string(),
525
525
+
"did:plc:alice123".to_string(),
526
526
+
);
527
527
+
handles_to_dids.insert(
528
528
+
"at://alice.bsky.social".to_string(),
529
529
+
"did:plc:alice123".to_string(),
530
530
+
);
531
531
+
Self { handles_to_dids }
532
532
+
}
533
533
+
534
534
+
fn add_identity(&mut self, handle: &str, did: &str) {
535
535
+
self.handles_to_dids
536
536
+
.insert(handle.to_string(), did.to_string());
537
537
+
self.handles_to_dids
538
538
+
.insert(format!("at://{}", handle), did.to_string());
539
539
+
}
540
540
+
}
541
541
+
542
542
+
#[async_trait]
543
543
+
impl IdentityResolver for MockIdentityResolver {
544
544
+
async fn resolve(&self, handle: &str) -> anyhow::Result<Document> {
545
545
+
let handle_key = if handle.starts_with("at://") {
546
546
+
handle.to_string()
547
547
+
} else {
548
548
+
handle.to_string()
549
549
+
};
550
550
+
551
551
+
if let Some(did) = self.handles_to_dids.get(&handle_key) {
552
552
+
Ok(Document {
553
553
+
context: vec![],
554
554
+
id: did.clone(),
555
555
+
also_known_as: vec![format!("at://{}", handle_key.trim_start_matches("at://"))],
556
556
+
verification_method: vec![],
557
557
+
service: vec![],
558
558
+
extra: HashMap::new(),
559
559
+
})
560
560
+
} else {
561
561
+
Err(anyhow::anyhow!("Handle not found"))
562
562
+
}
563
563
+
}
564
564
+
}
565
565
+
566
566
+
#[test]
567
567
+
fn test_html_escape() {
568
568
+
assert_eq!(html_escape("Hello & <world>"), "Hello & <world>");
569
569
+
assert_eq!(
570
570
+
html_escape("\"quotes\" and 'apostrophes'"),
571
571
+
""quotes" and 'apostrophes'"
572
572
+
);
573
573
+
assert_eq!(html_escape("Line 1\nLine 2"), "Line 1\nLine 2");
574
574
+
assert_eq!(html_escape("Normal text"), "Normal text");
575
575
+
}
576
576
+
577
577
+
#[test]
578
578
+
fn test_render_no_facets() {
579
579
+
let text = "This is a <test> description & it's great!";
580
580
+
let limits = FacetLimits::default();
581
581
+
let html = render_text_with_facets_html(text, None, &limits);
582
582
+
// HTML tags are detected and stripped by ammonia
583
583
+
// The <test> tag is removed entirely
584
584
+
assert_eq!(html, "This is a description & it's great!");
585
585
+
}
586
586
+
587
587
+
#[test]
588
588
+
fn test_render_with_html_tags() {
589
589
+
let text = "Check this <script>alert('XSS')</script> content!";
590
590
+
let limits = FacetLimits::default();
591
591
+
let html = render_text_with_facets_html(text, None, &limits);
592
592
+
// The script tag should be completely removed
593
593
+
assert_eq!(html, "Check this content!");
594
594
+
assert!(!html.contains("script"));
595
595
+
assert!(!html.contains("alert"));
596
596
+
}
597
597
+
598
598
+
#[test]
599
599
+
fn test_render_with_mention() {
600
600
+
let text = "Contact @alice.bsky.social for details";
601
601
+
let limits = FacetLimits::default();
602
602
+
let facets = vec![Facet {
603
603
+
index: ByteSlice {
604
604
+
byte_start: 8,
605
605
+
byte_end: 26,
606
606
+
},
607
607
+
features: vec![FacetFeature::Mention(Mention {
608
608
+
did: "did:plc:abc123".to_string(),
609
609
+
})],
610
610
+
}];
611
611
+
612
612
+
let html = render_text_with_facets_html(text, Some(&facets), &limits);
613
613
+
assert_eq!(
614
614
+
html,
615
615
+
r#"Contact <a href="/u/did:plc:abc123">@alice.bsky.social</a> for details"#
616
616
+
);
617
617
+
}
618
618
+
619
619
+
#[test]
620
620
+
fn test_render_with_link() {
621
621
+
let text = "Apply at https://example.com today!";
622
622
+
let limits = FacetLimits::default();
623
623
+
let facets = vec![Facet {
624
624
+
index: ByteSlice {
625
625
+
byte_start: 9,
626
626
+
byte_end: 28,
627
627
+
},
628
628
+
features: vec![FacetFeature::Link(Link {
629
629
+
uri: "https://example.com".to_string(),
630
630
+
})],
631
631
+
}];
632
632
+
633
633
+
let html = render_text_with_facets_html(text, Some(&facets), &limits);
634
634
+
assert_eq!(
635
635
+
html,
636
636
+
r#"Apply at <a href="https://example.com">https://example.com</a> today!"#
637
637
+
);
638
638
+
}
639
639
+
640
640
+
#[test]
641
641
+
fn test_render_with_tag() {
642
642
+
let text = "Looking for #rust developers";
643
643
+
let limits = FacetLimits::default();
644
644
+
let facets = vec![Facet {
645
645
+
index: ByteSlice {
646
646
+
byte_start: 12,
647
647
+
byte_end: 17,
648
648
+
},
649
649
+
features: vec![FacetFeature::Tag(Tag {
650
650
+
tag: "rust".to_string(),
651
651
+
})],
652
652
+
}];
653
653
+
654
654
+
let html = render_text_with_facets_html(text, Some(&facets), &limits);
655
655
+
assert_eq!(
656
656
+
html,
657
657
+
r#"Looking for <a href="/t/rust">#rust</a> developers"#
658
658
+
);
659
659
+
}
660
660
+
661
661
+
#[tokio::test]
662
662
+
async fn test_parse_facets_from_text_comprehensive() {
663
663
+
let mut resolver = MockIdentityResolver::new();
664
664
+
resolver.add_identity("bob.test.com", "did:plc:bob456");
665
665
+
666
666
+
let limits = FacetLimits::default();
667
667
+
let text = "Join @alice.bsky.social and @bob.test.com at https://example.com #rust #golang";
668
668
+
let facets = parse_facets_from_text(text, &resolver, &limits).await;
669
669
+
670
670
+
assert!(facets.is_some());
671
671
+
let facets = facets.unwrap();
672
672
+
assert_eq!(facets.len(), 5); // 2 mentions, 1 URL, 2 hashtags
673
673
+
674
674
+
// Check first mention
675
675
+
assert_eq!(facets[0].index.byte_start, 5);
676
676
+
assert_eq!(facets[0].index.byte_end, 23);
677
677
+
if let FacetFeature::Mention(ref mention) = facets[0].features[0] {
678
678
+
assert_eq!(mention.did, "did:plc:alice123");
679
679
+
} else {
680
680
+
panic!("Expected Mention feature");
681
681
+
}
682
682
+
683
683
+
// Check second mention
684
684
+
assert_eq!(facets[1].index.byte_start, 28);
685
685
+
assert_eq!(facets[1].index.byte_end, 41);
686
686
+
if let FacetFeature::Mention(ref mention) = facets[1].features[0] {
687
687
+
assert_eq!(mention.did, "did:plc:bob456");
688
688
+
} else {
689
689
+
panic!("Expected Mention feature");
690
690
+
}
691
691
+
692
692
+
// Check URL
693
693
+
assert_eq!(facets[2].index.byte_start, 45);
694
694
+
assert_eq!(facets[2].index.byte_end, 64);
695
695
+
if let FacetFeature::Link(ref link) = facets[2].features[0] {
696
696
+
assert_eq!(link.uri, "https://example.com");
697
697
+
} else {
698
698
+
panic!("Expected Link feature");
699
699
+
}
700
700
+
701
701
+
// Check first hashtag
702
702
+
assert_eq!(facets[3].index.byte_start, 65);
703
703
+
assert_eq!(facets[3].index.byte_end, 70);
704
704
+
if let FacetFeature::Tag(ref tag) = facets[3].features[0] {
705
705
+
assert_eq!(tag.tag, "rust");
706
706
+
} else {
707
707
+
panic!("Expected Tag feature");
708
708
+
}
709
709
+
710
710
+
// Check second hashtag
711
711
+
assert_eq!(facets[4].index.byte_start, 71);
712
712
+
assert_eq!(facets[4].index.byte_end, 78);
713
713
+
if let FacetFeature::Tag(ref tag) = facets[4].features[0] {
714
714
+
assert_eq!(tag.tag, "golang");
715
715
+
} else {
716
716
+
panic!("Expected Tag feature");
717
717
+
}
718
718
+
}
719
719
+
720
720
+
#[tokio::test]
721
721
+
async fn test_parse_facets_from_text_with_unresolvable_mention() {
722
722
+
let resolver = MockIdentityResolver::new();
723
723
+
let limits = FacetLimits::default();
724
724
+
725
725
+
// Only alice.bsky.social is in the resolver, not unknown.handle.com
726
726
+
let text = "Contact @unknown.handle.com for details #rust";
727
727
+
let facets = parse_facets_from_text(text, &resolver, &limits).await;
728
728
+
729
729
+
assert!(facets.is_some());
730
730
+
let facets = facets.unwrap();
731
731
+
// Should only have 1 facet (the hashtag) since the mention couldn't be resolved
732
732
+
assert_eq!(facets.len(), 1);
733
733
+
734
734
+
// Check that it's the hashtag facet
735
735
+
if let FacetFeature::Tag(ref tag) = facets[0].features[0] {
736
736
+
assert_eq!(tag.tag, "rust");
737
737
+
} else {
738
738
+
panic!("Expected Tag feature");
739
739
+
}
740
740
+
}
741
741
+
742
742
+
#[tokio::test]
743
743
+
async fn test_parse_facets_from_text_empty() {
744
744
+
let resolver = MockIdentityResolver::new();
745
745
+
let limits = FacetLimits::default();
746
746
+
let text = "No mentions, URLs, or hashtags here";
747
747
+
let facets = parse_facets_from_text(text, &resolver, &limits).await;
748
748
+
assert!(facets.is_none());
749
749
+
}
750
750
+
751
751
+
#[tokio::test]
752
752
+
async fn test_parse_facets_from_text_url_with_at_mention() {
753
753
+
let resolver = MockIdentityResolver::new();
754
754
+
let limits = FacetLimits::default();
755
755
+
756
756
+
// URLs with @ should not create mention facets
757
757
+
let text = "Tangled https://tangled.org/@smokesignal.events";
758
758
+
let facets = parse_facets_from_text(text, &resolver, &limits).await;
759
759
+
760
760
+
assert!(facets.is_some());
761
761
+
let facets = facets.unwrap();
762
762
+
763
763
+
// Should have exactly 1 facet (the URL), not 2 (URL + mention)
764
764
+
assert_eq!(
765
765
+
facets.len(),
766
766
+
1,
767
767
+
"Expected 1 facet (URL only), got {}",
768
768
+
facets.len()
769
769
+
);
770
770
+
771
771
+
// Verify it's a link facet, not a mention
772
772
+
if let FacetFeature::Link(ref link) = facets[0].features[0] {
773
773
+
assert_eq!(link.uri, "https://tangled.org/@smokesignal.events");
774
774
+
} else {
775
775
+
panic!("Expected Link feature, got Mention or Tag instead");
776
776
+
}
777
777
+
}
778
778
+
779
779
+
#[tokio::test]
780
780
+
async fn test_parse_facets_with_mention_limit() {
781
781
+
let mut resolver = MockIdentityResolver::new();
782
782
+
resolver.add_identity("bob.test.com", "did:plc:bob456");
783
783
+
resolver.add_identity("charlie.test.com", "did:plc:charlie789");
784
784
+
785
785
+
// Limit to 2 mentions
786
786
+
let limits = FacetLimits {
787
787
+
mentions_max: 2,
788
788
+
tags_max: 5,
789
789
+
links_max: 5,
790
790
+
max: 10,
791
791
+
};
792
792
+
793
793
+
let text = "Join @alice.bsky.social @bob.test.com @charlie.test.com";
794
794
+
let facets = parse_facets_from_text(text, &resolver, &limits).await;
795
795
+
796
796
+
assert!(facets.is_some());
797
797
+
let facets = facets.unwrap();
798
798
+
// Should only have 2 mentions (alice and bob), charlie should be skipped
799
799
+
assert_eq!(facets.len(), 2);
800
800
+
801
801
+
// Verify they're both mentions
802
802
+
for facet in &facets {
803
803
+
assert!(matches!(facet.features[0], FacetFeature::Mention(_)));
804
804
+
}
805
805
+
}
806
806
+
807
807
+
#[tokio::test]
808
808
+
async fn test_parse_facets_with_global_limit() {
809
809
+
let mut resolver = MockIdentityResolver::new();
810
810
+
resolver.add_identity("bob.test.com", "did:plc:bob456");
811
811
+
812
812
+
// Very restrictive global limit
813
813
+
let limits = FacetLimits {
814
814
+
mentions_max: 5,
815
815
+
tags_max: 5,
816
816
+
links_max: 5,
817
817
+
max: 3, // Only allow 3 total facets
818
818
+
};
819
819
+
820
820
+
let text = "Join @alice.bsky.social @bob.test.com at https://example.com #rust #golang #python";
821
821
+
let facets = parse_facets_from_text(text, &resolver, &limits).await;
822
822
+
823
823
+
assert!(facets.is_some());
824
824
+
let facets = facets.unwrap();
825
825
+
// Should be truncated to 3 facets total
826
826
+
assert_eq!(facets.len(), 3);
827
827
+
}
828
828
+
829
829
+
#[test]
830
830
+
fn test_render_with_facet_limits() {
831
831
+
let text = "Contact @alice @bob @charlie for details";
832
832
+
let limits = FacetLimits {
833
833
+
mentions_max: 2, // Only render first 2 mentions
834
834
+
tags_max: 5,
835
835
+
links_max: 5,
836
836
+
max: 10,
837
837
+
};
838
838
+
839
839
+
let facets = vec![
840
840
+
Facet {
841
841
+
index: ByteSlice {
842
842
+
byte_start: 8,
843
843
+
byte_end: 14,
844
844
+
},
845
845
+
features: vec![FacetFeature::Mention(Mention {
846
846
+
did: "did:plc:alice".to_string(),
847
847
+
})],
848
848
+
},
849
849
+
Facet {
850
850
+
index: ByteSlice {
851
851
+
byte_start: 15,
852
852
+
byte_end: 19,
853
853
+
},
854
854
+
features: vec![FacetFeature::Mention(Mention {
855
855
+
did: "did:plc:bob".to_string(),
856
856
+
})],
857
857
+
},
858
858
+
Facet {
859
859
+
index: ByteSlice {
860
860
+
byte_start: 20,
861
861
+
byte_end: 28,
862
862
+
},
863
863
+
features: vec![FacetFeature::Mention(Mention {
864
864
+
did: "did:plc:charlie".to_string(),
865
865
+
})],
866
866
+
},
867
867
+
];
868
868
+
869
869
+
let html = render_text_with_facets_html(text, Some(&facets), &limits);
870
870
+
// Should only render first 2 mentions, third should be plain text
871
871
+
assert!(html.contains(r#"<a href="/u/did:plc:alice">@alice</a>"#));
872
872
+
assert!(html.contains(r#"<a href="/u/did:plc:bob">@bob</a>"#));
873
873
+
// Charlie should NOT be a link due to mention limit
874
874
+
assert!(!html.contains(r#"<a href="/u/did:plc:charlie">"#));
875
875
+
}
876
876
+
877
877
+
#[test]
878
878
+
fn test_render_malicious_link() {
879
879
+
let text = "Visit example.com for details";
880
880
+
let limits = FacetLimits::default();
881
881
+
let facets = vec![Facet {
882
882
+
index: ByteSlice {
883
883
+
byte_start: 6,
884
884
+
byte_end: 17,
885
885
+
},
886
886
+
features: vec![FacetFeature::Link(Link {
887
887
+
uri: "javascript:alert('XSS')".to_string(),
888
888
+
})],
889
889
+
}];
890
890
+
891
891
+
let html = render_text_with_facets_html(text, Some(&facets), &limits);
892
892
+
// JavaScript URLs should be blocked
893
893
+
assert!(!html.contains("javascript:"));
894
894
+
assert_eq!(html, "Visit example.com for details");
895
895
+
}
896
896
+
}
+25
-2
src/http/event_view.rs
···
42
42
pub name: String,
43
43
pub description: Option<String>,
44
44
pub description_short: Option<String>,
45
45
+
pub description_html: Option<String>,
45
46
46
47
pub count_going: u32,
47
48
pub count_notgoing: u32,
···
55
56
pub header: Option<(String, String)>, // (cid, alt text)
56
57
}
57
58
58
58
-
impl TryFrom<(Option<&IdentityProfile>, Option<&IdentityProfile>, &Event)> for EventView {
59
59
+
impl TryFrom<(Option<&IdentityProfile>, Option<&IdentityProfile>, &Event, &crate::facets::FacetLimits)> for EventView {
59
60
type Error = anyhow::Error;
60
61
61
62
fn try_from(
62
62
-
(viewer, organizer, event): (Option<&IdentityProfile>, Option<&IdentityProfile>, &Event),
63
63
+
(viewer, organizer, event, facet_limits): (Option<&IdentityProfile>, Option<&IdentityProfile>, &Event, &crate::facets::FacetLimits),
63
64
) -> Result<Self, Self::Error> {
64
65
// Time zones are used to display date/time values from the perspective
65
66
// of the viewer. The timezone is selected with this priority:
···
147
148
.as_ref()
148
149
.map(|value| truncate_text(value, 200, Some("...".to_string())).to_string());
149
150
151
151
+
// Extract facets from the event record and render HTML description
152
152
+
let description_html = if let Some(desc_text) = &description {
153
153
+
// Try to extract facets from the event record's extra fields
154
154
+
let facets = event
155
155
+
.record
156
156
+
.as_object()
157
157
+
.and_then(|obj| obj.get("facets"))
158
158
+
.and_then(|facets_value| {
159
159
+
serde_json::from_value::<Vec<atproto_record::lexicon::app::bsky::richtext::facet::Facet>>(facets_value.clone()).ok()
160
160
+
});
161
161
+
162
162
+
// Render the description with facets
163
163
+
Some(crate::facets::render_text_with_facets_html(
164
164
+
desc_text,
165
165
+
facets.as_ref(),
166
166
+
facet_limits,
167
167
+
))
168
168
+
} else {
169
169
+
None
170
170
+
};
171
171
+
150
172
let starts_at_human = starts_at.as_ref().map(|value| {
151
173
value
152
174
.with_timezone(&tz)
···
209
231
name,
210
232
description,
211
233
description_short,
234
234
+
description_html,
212
235
count_going: 0,
213
236
count_notgoing: 0,
214
237
count_interested: 0,
+26
-4
src/http/handle_create_event.rs
···
255
255
None => vec![],
256
256
};
257
257
258
258
+
// Parse facets from description if present
259
259
+
let description = build_event_form
260
260
+
.description
261
261
+
.clone()
262
262
+
.ok_or(CreateEventError::DescriptionNotSet)?;
263
263
+
264
264
+
let facet_limits = crate::facets::FacetLimits {
265
265
+
mentions_max: web_context.config.facets_mentions_max,
266
266
+
tags_max: web_context.config.facets_tags_max,
267
267
+
links_max: web_context.config.facets_links_max,
268
268
+
max: web_context.config.facets_max,
269
269
+
};
270
270
+
271
271
+
let facets = if !description.is_empty() {
272
272
+
crate::facets::parse_facets_from_text(
273
273
+
&description,
274
274
+
web_context.identity_resolver.as_ref(),
275
275
+
&facet_limits,
276
276
+
)
277
277
+
.await
278
278
+
} else {
279
279
+
None
280
280
+
};
281
281
+
258
282
let the_record = Event {
259
283
name: build_event_form
260
284
.name
261
285
.clone()
262
286
.ok_or(CreateEventError::NameNotSet)?,
263
263
-
description: build_event_form
264
264
-
.description
265
265
-
.clone()
266
266
-
.ok_or(CreateEventError::DescriptionNotSet)?,
287
287
+
description,
267
288
created_at: now,
268
289
starts_at,
269
290
ends_at,
···
272
293
locations,
273
294
uris: links,
274
295
media: Vec::default(),
296
296
+
facets,
275
297
extra: HashMap::default(),
276
298
};
277
299
+28
-4
src/http/handle_edit_event.rs
···
549
549
// Extract existing extra fields from the original record
550
550
let extra = community_event.extra.clone();
551
551
552
552
+
// Parse facets from updated description or preserve existing facets
553
553
+
let description = build_event_form
554
554
+
.description
555
555
+
.clone()
556
556
+
.ok_or(CommonError::FieldRequired)?;
557
557
+
558
558
+
let facet_limits = crate::facets::FacetLimits {
559
559
+
mentions_max: ctx.web_context.config.facets_mentions_max,
560
560
+
tags_max: ctx.web_context.config.facets_tags_max,
561
561
+
links_max: ctx.web_context.config.facets_links_max,
562
562
+
max: ctx.web_context.config.facets_max,
563
563
+
};
564
564
+
565
565
+
let facets = if !description.is_empty() {
566
566
+
// Extract facets from the updated description
567
567
+
crate::facets::parse_facets_from_text(
568
568
+
&description,
569
569
+
ctx.web_context.identity_resolver.as_ref(),
570
570
+
&facet_limits,
571
571
+
)
572
572
+
.await
573
573
+
} else {
574
574
+
// If description is empty, preserve existing facets
575
575
+
community_event.facets.clone()
576
576
+
};
577
577
+
552
578
let updated_record = LexiconCommunityEvent {
553
579
name: build_event_form
554
580
.name
555
581
.clone()
556
582
.ok_or(CommonError::FieldRequired)?,
557
557
-
description: build_event_form
558
558
-
.description
559
559
-
.clone()
560
560
-
.ok_or(CommonError::FieldRequired)?,
583
583
+
description,
561
584
created_at: community_event.created_at,
562
585
starts_at,
563
586
ends_at,
···
566
589
locations,
567
590
uris,
568
591
media: Vec::default(),
592
592
+
facets,
569
593
extra, // Use the preserved extra fields
570
594
};
571
595
+8
src/http/handle_profile.rs
···
416
416
let organizer_handlers =
417
417
hydrate_event_organizers(&ctx.web_context.pool, &events).await?;
418
418
419
419
+
let facet_limits = crate::facets::FacetLimits {
420
420
+
mentions_max: ctx.web_context.config.facets_mentions_max,
421
421
+
tags_max: ctx.web_context.config.facets_tags_max,
422
422
+
links_max: ctx.web_context.config.facets_links_max,
423
423
+
max: ctx.web_context.config.facets_max,
424
424
+
};
425
425
+
419
426
let mut events = events
420
427
.iter()
421
428
.filter_map(|event_view| {
···
424
431
ctx.current_handle.as_ref(),
425
432
organizer_maybe,
426
433
&event_view.event,
434
434
+
&facet_limits,
427
435
))
428
436
.ok()
429
437
})
+8
src/http/handle_view_event.rs
···
225
225
}
226
226
};
227
227
228
228
+
let facet_limits = crate::facets::FacetLimits {
229
229
+
mentions_max: ctx.web_context.config.facets_mentions_max,
230
230
+
tags_max: ctx.web_context.config.facets_tags_max,
231
231
+
links_max: ctx.web_context.config.facets_links_max,
232
232
+
max: ctx.web_context.config.facets_max,
233
233
+
};
234
234
+
228
235
EventView::try_from((
229
236
ctx.current_handle.as_ref(),
230
237
organizer_handle.as_ref(),
231
238
event,
239
239
+
&facet_limits,
232
240
))
233
241
}
234
242
Err(err) => Err(ViewEventError::EventNotFound(err.to_string()).into()),
+1
src/lib.rs
···
3
3
pub mod config_errors;
4
4
pub mod consumer;
5
5
pub mod errors;
6
6
+
pub mod facets;
6
7
pub mod http;
7
8
pub mod i18n;
8
9
pub mod key_provider;
+1
src/task_search_indexer.rs
···
69
69
"handle": { "type": "keyword" },
70
70
"name": { "type": "text" },
71
71
"description": { "type": "text" },
72
72
+
"tags": { "type": "keyword" },
72
73
"start_time": { "type": "date" },
73
74
"end_time": { "type": "date" },
74
75
"created_at": { "type": "date" },