This commit is contained in:
rustmailer
2026-04-21 21:23:28 +08:00
parent 51329fb2e1
commit c14834abe8
6 changed files with 90 additions and 40 deletions

View File

@@ -29,11 +29,11 @@ use crate::modules::rest::ErrorCode;
use crate::modules::store::tantivy::attachment::ATTACHMENT_MANAGER; use crate::modules::store::tantivy::attachment::ATTACHMENT_MANAGER;
use crate::modules::store::tantivy::model::AttachmentModel; use crate::modules::store::tantivy::model::AttachmentModel;
use crate::modules::users::permissions::Permission; use crate::modules::users::permissions::Permission;
use crate::modules::utils::validate_tag;
use crate::raise_error; use crate::raise_error;
use poem_openapi::param::Path; use poem_openapi::param::Path;
use poem_openapi::payload::Json; use poem_openapi::payload::Json;
use poem_openapi::OpenApi; use poem_openapi::OpenApi;
use tantivy::schema::Facet;
use std::collections::HashSet; use std::collections::HashSet;
pub struct AttachmentApi; pub struct AttachmentApi;
@@ -133,8 +133,8 @@ impl AttachmentApi {
) -> ApiResult<()> { ) -> ApiResult<()> {
let req = req.0; let req = req.0;
for tag in &req.tags { for tag in &req.tags {
validate_tag(tag) Facet::from_text(tag)
.map_err(|e| raise_error!(format!("{}", e), ErrorCode::InvalidParameter))?; .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
} }
for account_id in req.updates.keys() { for account_id in req.updates.keys() {

View File

@@ -38,12 +38,12 @@ use crate::modules::store::envelope::Envelope;
use crate::modules::store::storage::get_reader; use crate::modules::store::storage::get_reader;
use crate::modules::store::tantivy::envelope::ENVELOPE_MANAGER; use crate::modules::store::tantivy::envelope::ENVELOPE_MANAGER;
use crate::modules::users::permissions::Permission; use crate::modules::users::permissions::Permission;
use crate::modules::utils::validate_tag;
use crate::raise_error; use crate::raise_error;
use poem::Body; use poem::Body;
use poem_openapi::param::{Path, Query}; use poem_openapi::param::{Path, Query};
use poem_openapi::payload::{Attachment, AttachmentType, Json}; use poem_openapi::payload::{Attachment, AttachmentType, Json};
use poem_openapi::OpenApi; use poem_openapi::OpenApi;
use tantivy::schema::Facet;
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::HashSet; use std::collections::HashSet;
@@ -350,8 +350,8 @@ impl MessageApi {
) -> ApiResult<()> { ) -> ApiResult<()> {
let req = req.0; let req = req.0;
for tag in &req.tags { for tag in &req.tags {
validate_tag(tag) Facet::from_text(tag)
.map_err(|e| raise_error!(format!("{}", e), ErrorCode::InvalidParameter))?; .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
} }
for account_id in req.updates.keys() { for account_id in req.updates.keys() {

View File

@@ -1,7 +1,28 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem_openapi::Object; use poem_openapi::Object;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet; use std::collections::HashSet;
use tantivy::{schema::Value, TantivyDocument}; use tantivy::{
schema::{Facet, Value},
TantivyDocument,
};
use crate::{ use crate::{
modules::{ modules::{
@@ -107,7 +128,12 @@ impl EnvelopeWithAttachments {
let tags: Vec<String> = doc let tags: Vec<String> = doc
.get_all(fields.f_tags) .get_all(fields.f_tags)
.filter_map(|value| value.as_facet()) .filter_map(|value| value.as_facet())
.map(|f| f.to_string()) .map(|facet_encoded_str| {
Facet::from_encoded(facet_encoded_str.as_bytes().to_vec())
.ok()
.map(|facet| facet.to_string())
})
.flatten()
.collect(); .collect();
let account_id = extract_u64_field(doc, fields.f_account_id, F_ACCOUNT_ID)?; let account_id = extract_u64_field(doc, fields.f_account_id, F_ACCOUNT_ID)?;
@@ -361,14 +387,25 @@ impl AttachmentModel {
let tags: Vec<String> = doc let tags: Vec<String> = doc
.get_all(f.f_tags) .get_all(f.f_tags)
.filter_map(|value| value.as_facet()) .filter_map(|value| value.as_facet())
.map(|f| f.to_string()) .map(|facet_encoded_str| {
Facet::from_encoded(facet_encoded_str.as_bytes().to_vec())
.ok()
.map(|facet| facet.to_string())
})
.flatten()
.collect(); .collect();
let auto_tags: Vec<String> = doc let auto_tags: Vec<String> = doc
.get_all(f.f_auto_tags) .get_all(f.f_auto_tags)
.filter_map(|value| value.as_facet()) .filter_map(|value| value.as_facet())
.map(|f| f.to_string()) .map(|facet_encoded_str| {
Facet::from_encoded(facet_encoded_str.as_bytes().to_vec())
.ok()
.map(|facet| facet.to_string())
})
.flatten()
.collect(); .collect();
let account_id = extract_u64_field(doc, f.f_account_id, F_ACCOUNT_ID)?; let account_id = extract_u64_field(doc, f.f_account_id, F_ACCOUNT_ID)?;
let mailbox_id = extract_u64_field(doc, f.f_mailbox_id, F_MAILBOX_ID)?; let mailbox_id = extract_u64_field(doc, f.f_mailbox_id, F_MAILBOX_ID)?;
let account = AccountModel::get(account_id)?; let account = AccountModel::get(account_id)?;

View File

@@ -347,31 +347,6 @@ pub fn decode_avatar_bytes(base64_str: &str) -> BichonResult<Vec<u8>> {
Ok(bytes) Ok(bytes)
} }
pub fn validate_tag(tag: &str) -> Result<(), String> {
if tag.is_empty() {
return Err("Tag cannot be empty".to_string());
}
const INVALID: &[char] = &[
'\'', '"', '`', ';', ',', '(', ')', '[', ']', '{', '}', '<', '>',
];
let mut found = Vec::new();
for c in tag.chars() {
if INVALID.contains(&c) && !found.contains(&c) {
found.push(c);
}
}
if !found.is_empty() {
let chars: String = found.iter().collect();
return Err(format!("Tag contains invalid characters: {}", chars));
}
Ok(())
}
pub fn compute_content_hash(content: &[u8]) -> String { pub fn compute_content_hash(content: &[u8]) -> String {
let hash = blake3::hash(content); let hash = blake3::hash(content);
hash.to_hex().to_string() hash.to_hex().to_string()

View File

@@ -173,7 +173,28 @@ export function MailListTable({
{ {
accessorKey: "subject", accessorKey: "subject",
header: t('search.subject'), header: t('search.subject'),
cell: ({ row }) => <LongText className='text-xs'>{row.original.subject}</LongText>, cell: ({ row }) => {
const tags = row.original.tags ?? [];
return (
<div className="flex flex-col gap-1 py-1.5 min-w-0">
<LongText className='text-xs font-medium truncate'>
{row.original.subject}
</LongText>
{tags.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-md bg-primary/15 px-2 py-0.5 text-[10px] font-semibold text-primary ring-1 ring-inset ring-primary/20"
>
{tag}
</span>
))}
</div>
)}
</div>
);
},
meta: { className: 'text-left text-xs' } meta: { className: 'text-left text-xs' }
}, },
{ {

View File

@@ -88,12 +88,28 @@ export function validateTag(facetPath: string) {
}; };
} }
const invalidChars = /['"`;,()[\]{}<>]/; if (!facetPath.startsWith('/')) {
if (invalidChars.test(facetPath)) {
return { return {
valid: false, valid: false,
error: "Tag path contains invalid characters" error: "Tag path must start with '/'"
};
}
let escaped = false;
for (let i = 1; i < facetPath.length; i++) {
const char = facetPath[i];
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
}
}
if (escaped) {
return {
valid: false,
error: "Tag path has unmatched escape character at the end"
}; };
} }
@@ -101,6 +117,7 @@ export function validateTag(facetPath: string) {
} }
export function formatTimestamp(milliseconds: number): string { export function formatTimestamp(milliseconds: number): string {
const date = new Date(milliseconds); const date = new Date(milliseconds);
const year = date.getFullYear(); const year = date.getFullYear();