VFS/DataProvider/Config refactoring + SSH public key authentication
Phase 1-6 of refactoring plan: - VFS abstraction (VfsBackend trait + LocalFs + OpenFlags builder) - DataProvider trait (SqliteProvider + PgProvider, SFTPGo-compatible) - Config refactoring (AppConfig unified sections, env overrides) - SSH handlers (sftp/scp/rsync) migrated to VFS + DataProvider - SSH public key authentication (Ed25519 signature verification) - SSH stderr → CHANNEL_EXTENDED_DATA support - Web auth uses DataProvider instead of direct SQL - User home directory from provider (per-user isolation) - PostgreSQL auth provider for SFTPGo compatibility
This commit is contained in:
212
markbase-core/src/vfs/local_fs.rs
Normal file
212
markbase-core/src/vfs/local_fs.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use super::util;
|
||||
use super::open_flags::OpenFlags;
|
||||
use super::{VfsBackend, VfsDirEntry, VfsError, VfsFile, VfsStat};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
|
||||
/// 本地文件系统实现(直接包装 std::fs,不做路径解析)
|
||||
/// 路径解析由上层(SftpHandler)负责
|
||||
pub struct LocalFs;
|
||||
|
||||
impl LocalFs {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalFile {
|
||||
file: File,
|
||||
}
|
||||
|
||||
impl VfsFile for LocalFile {
|
||||
fn read(&mut self, buf: &mut [u8]) -> Result<usize, VfsError> {
|
||||
self.file.read(buf).map_err(|e| VfsError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
fn write(&mut self, buf: &[u8]) -> Result<usize, VfsError> {
|
||||
self.file.write(buf).map_err(|e| VfsError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
fn seek(&mut self, pos: SeekFrom) -> Result<u64, VfsError> {
|
||||
self.file.seek(pos).map_err(|e| VfsError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<(), VfsError> {
|
||||
self.file.flush().map_err(|e| VfsError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
fn stat(&mut self) -> Result<VfsStat, VfsError> {
|
||||
let meta = self.file.metadata().map_err(|e| VfsError::Io(e.to_string()))?;
|
||||
Ok(util::stat_from_metadata(&meta, false))
|
||||
}
|
||||
|
||||
fn set_len(&mut self, size: u64) -> Result<(), VfsError> {
|
||||
self.file.set_len(size).map_err(|e| VfsError::Io(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl VfsBackend for LocalFs {
|
||||
fn read_dir(&self, path: &Path) -> Result<Vec<VfsDirEntry>, VfsError> {
|
||||
let dir = fs::read_dir(path).map_err(|e| util::map_io_error(path, e))?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for entry in dir {
|
||||
let entry = entry.map_err(|e| util::map_io_error(path, e))?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let file_type = entry.file_type().map_err(|e| util::map_io_error(path, e))?;
|
||||
let meta = entry.metadata().map_err(|e| util::map_io_error(path, e))?;
|
||||
let stat = util::stat_from_metadata(&meta, file_type.is_symlink());
|
||||
let long_name = util::build_long_name(&stat, &name);
|
||||
|
||||
entries.push(VfsDirEntry {
|
||||
name,
|
||||
long_name,
|
||||
stat,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn open_file(&self, path: &Path, flags: &OpenFlags) -> Result<Box<dyn VfsFile>, VfsError> {
|
||||
let mut opts = OpenOptions::new();
|
||||
opts.read(flags.read);
|
||||
opts.write(flags.write);
|
||||
opts.append(flags.append);
|
||||
opts.create(flags.create);
|
||||
opts.truncate(flags.truncate);
|
||||
opts.create_new(flags.exclusive);
|
||||
|
||||
let file = opts.open(path).map_err(|e| util::map_io_error(path, e))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
if flags.create && !flags.exclusive {
|
||||
if let Ok(meta) = file.metadata() {
|
||||
if flags.mode != 0 && meta.permissions().mode() != flags.mode {
|
||||
fs::set_permissions(path, std::fs::Permissions::from_mode(flags.mode))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Box::new(LocalFile { file }))
|
||||
}
|
||||
|
||||
fn stat(&self, path: &Path) -> Result<VfsStat, VfsError> {
|
||||
let meta = fs::metadata(path).map_err(|e| util::map_io_error(path, e))?;
|
||||
Ok(util::stat_from_metadata(&meta, false))
|
||||
}
|
||||
|
||||
fn lstat(&self, path: &Path) -> Result<VfsStat, VfsError> {
|
||||
let meta = fs::symlink_metadata(path).map_err(|e| util::map_io_error(path, e))?;
|
||||
let is_symlink = path.is_symlink() || meta.file_type().is_symlink();
|
||||
Ok(util::stat_from_metadata(&meta, is_symlink))
|
||||
}
|
||||
|
||||
fn create_dir(&self, path: &Path, mode: u32) -> Result<(), VfsError> {
|
||||
fs::create_dir(path).map_err(|e| util::map_io_error(path, e))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
|
||||
.map_err(|e| util::map_io_error(path, e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_dir_all(&self, path: &Path, mode: u32) -> Result<(), VfsError> {
|
||||
fs::create_dir_all(path).map_err(|e| util::map_io_error(path, e))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if mode != 0 {
|
||||
fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
|
||||
.map_err(|e| util::map_io_error(path, e))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_dir(&self, path: &Path) -> Result<(), VfsError> {
|
||||
fs::remove_dir(path).map_err(|e| util::map_io_error(path, e))
|
||||
}
|
||||
|
||||
fn remove_file(&self, path: &Path) -> Result<(), VfsError> {
|
||||
fs::remove_file(path).map_err(|e| util::map_io_error(path, e))
|
||||
}
|
||||
|
||||
fn rename(&self, from: &Path, to: &Path) -> Result<(), VfsError> {
|
||||
fs::rename(from, to).map_err(|e| util::map_io_error(from, e))
|
||||
}
|
||||
|
||||
fn set_stat(&self, path: &Path, stat: &VfsStat) -> Result<(), VfsError> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if stat.mode != 0 {
|
||||
fs::set_permissions(path, std::fs::Permissions::from_mode(stat.mode))
|
||||
.map_err(|e| util::map_io_error(path, e))?;
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(atime), Some(mtime)) = (
|
||||
stat.atime.duration_since(std::time::UNIX_EPOCH).ok(),
|
||||
stat.mtime.duration_since(std::time::UNIX_EPOCH).ok(),
|
||||
) {
|
||||
filetime::set_file_times(path,
|
||||
filetime::FileTime::from_unix_time(atime.as_secs() as i64, 0),
|
||||
filetime::FileTime::from_unix_time(mtime.as_secs() as i64, 0),
|
||||
).map_err(|e| util::map_io_error(path, e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_link(&self, path: &Path) -> Result<PathBuf, VfsError> {
|
||||
let target = fs::read_link(path).map_err(|e| util::map_io_error(path, e))?;
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn create_symlink(&self, target: &Path, link: &Path) -> Result<(), VfsError> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(target, link)
|
||||
.map_err(|e| util::map_io_error(link, e))?;
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::os::windows::fs::symlink_file(target, link)
|
||||
.map_err(|e| util::map_io_error(link, e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn real_path(&self, path: &Path) -> Result<PathBuf, VfsError> {
|
||||
let canonical = path.canonicalize().map_err(|e| util::map_io_error(path, e))?;
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn exists(&self, path: &Path) -> bool {
|
||||
path.exists()
|
||||
}
|
||||
|
||||
fn hard_link(&self, original: &Path, link: &Path) -> Result<(), VfsError> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
fs::hard_link(original, link).map_err(|e| util::map_io_error(original, e))?;
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
return Err(VfsError::Unsupported("hard_link not supported on non-Unix systems".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
160
markbase-core/src/vfs/mod.rs
Normal file
160
markbase-core/src/vfs/mod.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
pub mod open_flags;
|
||||
pub mod local_fs;
|
||||
pub mod util;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// VFS 错误类型
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum VfsError {
|
||||
NotFound(String),
|
||||
PermissionDenied(String),
|
||||
AlreadyExists(String),
|
||||
NotEmpty(String),
|
||||
NotADirectory(String),
|
||||
IsADirectory(String),
|
||||
Unsupported(String),
|
||||
Io(String),
|
||||
UnexpectedEof,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VfsError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
VfsError::NotFound(p) => write!(f, "No such file or directory: {}", p),
|
||||
VfsError::PermissionDenied(p) => write!(f, "Permission denied: {}", p),
|
||||
VfsError::AlreadyExists(p) => write!(f, "File already exists: {}", p),
|
||||
VfsError::NotEmpty(p) => write!(f, "Directory not empty: {}", p),
|
||||
VfsError::NotADirectory(p) => write!(f, "Not a directory: {}", p),
|
||||
VfsError::IsADirectory(p) => write!(f, "Is a directory: {}", p),
|
||||
VfsError::Unsupported(msg) => write!(f, "Unsupported: {}", msg),
|
||||
VfsError::Io(msg) => write!(f, "IO error: {}", msg),
|
||||
VfsError::UnexpectedEof => write!(f, "Unexpected end of file"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VfsError {}
|
||||
|
||||
/// 文件统计信息(类似 libc::stat)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VfsStat {
|
||||
pub size: u64,
|
||||
pub mode: u32,
|
||||
pub uid: u32,
|
||||
pub gid: u32,
|
||||
pub atime: SystemTime,
|
||||
pub mtime: SystemTime,
|
||||
pub is_dir: bool,
|
||||
pub is_symlink: bool,
|
||||
}
|
||||
|
||||
impl VfsStat {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
size: 0,
|
||||
mode: 0,
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
atime: SystemTime::UNIX_EPOCH,
|
||||
mtime: SystemTime::UNIX_EPOCH,
|
||||
is_dir: false,
|
||||
is_symlink: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for VfsStat {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 目录条目
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VfsDirEntry {
|
||||
pub name: String,
|
||||
pub long_name: String,
|
||||
pub stat: VfsStat,
|
||||
}
|
||||
|
||||
/// 打开文件的抽象
|
||||
pub trait VfsFile {
|
||||
fn read(&mut self, buf: &mut [u8]) -> Result<usize, VfsError>;
|
||||
fn write(&mut self, buf: &[u8]) -> Result<usize, VfsError>;
|
||||
fn seek(&mut self, pos: std::io::SeekFrom) -> Result<u64, VfsError>;
|
||||
fn flush(&mut self) -> Result<(), VfsError>;
|
||||
fn stat(&mut self) -> Result<VfsStat, VfsError>;
|
||||
fn set_len(&mut self, size: u64) -> Result<(), VfsError>;
|
||||
|
||||
/// Write all bytes (convenience, default loops write() until done)
|
||||
fn write_all(&mut self, mut buf: &[u8]) -> Result<(), VfsError> {
|
||||
while !buf.is_empty() {
|
||||
let n = self.write(buf)?;
|
||||
if n == 0 {
|
||||
return Err(VfsError::Io("write returned 0".to_string()));
|
||||
}
|
||||
buf = &buf[n..];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read exactly `buf.len()` bytes (convenience, loops read() until done)
|
||||
fn read_exact(&mut self, mut buf: &mut [u8]) -> Result<(), VfsError> {
|
||||
while !buf.is_empty() {
|
||||
let n = self.read(buf)?;
|
||||
if n == 0 {
|
||||
return Err(VfsError::UnexpectedEof);
|
||||
}
|
||||
buf = &mut buf[n..];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// VFS 后端 trait(所有文件系统操作)
|
||||
pub trait VfsBackend: Send {
|
||||
/// 读取目录内容
|
||||
fn read_dir(&self, path: &Path) -> Result<Vec<VfsDirEntry>, VfsError>;
|
||||
|
||||
/// 打开文件(读/写)
|
||||
fn open_file(&self, path: &Path, flags: &open_flags::OpenFlags) -> Result<Box<dyn VfsFile>, VfsError>;
|
||||
|
||||
/// 获取文件/目录元数据
|
||||
fn stat(&self, path: &Path) -> Result<VfsStat, VfsError>;
|
||||
fn lstat(&self, path: &Path) -> Result<VfsStat, VfsError>;
|
||||
|
||||
/// 创建目录
|
||||
fn create_dir(&self, path: &Path, mode: u32) -> Result<(), VfsError>;
|
||||
|
||||
/// 递归创建目录
|
||||
fn create_dir_all(&self, path: &Path, mode: u32) -> Result<(), VfsError>;
|
||||
|
||||
/// 删除空目录
|
||||
fn remove_dir(&self, path: &Path) -> Result<(), VfsError>;
|
||||
|
||||
/// 删除文件
|
||||
fn remove_file(&self, path: &Path) -> Result<(), VfsError>;
|
||||
|
||||
/// 重命名
|
||||
fn rename(&self, from: &Path, to: &Path) -> Result<(), VfsError>;
|
||||
|
||||
/// 设置文件属性
|
||||
fn set_stat(&self, path: &Path, stat: &VfsStat) -> Result<(), VfsError>;
|
||||
|
||||
/// 读取符号链接目标
|
||||
fn read_link(&self, path: &Path) -> Result<PathBuf, VfsError>;
|
||||
|
||||
/// 创建符号链接
|
||||
fn create_symlink(&self, target: &Path, link: &Path) -> Result<(), VfsError>;
|
||||
|
||||
/// 规范化路径
|
||||
fn real_path(&self, path: &Path) -> Result<PathBuf, VfsError>;
|
||||
|
||||
/// 检查路径是否存在
|
||||
fn exists(&self, path: &Path) -> bool;
|
||||
|
||||
/// 创建硬链接
|
||||
fn hard_link(&self, original: &Path, link: &Path) -> Result<(), VfsError>;
|
||||
}
|
||||
75
markbase-core/src/vfs/open_flags.rs
Normal file
75
markbase-core/src/vfs/open_flags.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
/// 文件打开标志(映射 SSH_FXF_* 和 POSIX open flags)
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct OpenFlags {
|
||||
pub read: bool,
|
||||
pub write: bool,
|
||||
pub append: bool,
|
||||
pub create: bool,
|
||||
pub truncate: bool,
|
||||
pub exclusive: bool,
|
||||
pub mode: u32,
|
||||
}
|
||||
|
||||
impl OpenFlags {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn read(mut self) -> Self {
|
||||
self.read = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn write(mut self) -> Self {
|
||||
self.write = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn append(mut self) -> Self {
|
||||
self.append = true;
|
||||
self.write = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn create(mut self) -> Self {
|
||||
self.create = true;
|
||||
self.write = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn truncate(mut self) -> Self {
|
||||
self.truncate = true;
|
||||
self.write = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn exclusive(mut self) -> Self {
|
||||
self.exclusive = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn mode(mut self, mode: u32) -> Self {
|
||||
self.mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
/// 从 SFTP 的 pflags(SSH_FXF_*)构建 OpenFlags
|
||||
pub fn from_sftp_pflags(pflags: u32) -> Self {
|
||||
let read = pflags & 0x00000001 != 0;
|
||||
let write = pflags & 0x00000002 != 0;
|
||||
let append = pflags & 0x00000004 != 0;
|
||||
let create = pflags & 0x00000008 != 0;
|
||||
let truncate = pflags & 0x00000010 != 0;
|
||||
let exclusive = pflags & 0x00000020 != 0;
|
||||
|
||||
Self {
|
||||
read,
|
||||
write,
|
||||
append,
|
||||
create,
|
||||
truncate,
|
||||
exclusive,
|
||||
mode: 0o644,
|
||||
}
|
||||
}
|
||||
}
|
||||
105
markbase-core/src/vfs/util.rs
Normal file
105
markbase-core/src/vfs/util.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use super::{VfsError, VfsStat};
|
||||
use chrono::Datelike;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
|
||||
/// 从 std::io::ErrorKind 映射 VfsError
|
||||
pub fn map_io_error(path: &Path, e: std::io::Error) -> VfsError {
|
||||
match e.kind() {
|
||||
std::io::ErrorKind::NotFound => VfsError::NotFound(path.display().to_string()),
|
||||
std::io::ErrorKind::PermissionDenied => VfsError::PermissionDenied(path.display().to_string()),
|
||||
std::io::ErrorKind::AlreadyExists => VfsError::AlreadyExists(path.display().to_string()),
|
||||
std::io::ErrorKind::DirectoryNotEmpty => VfsError::NotEmpty(path.display().to_string()),
|
||||
std::io::ErrorKind::NotADirectory => VfsError::NotADirectory(path.display().to_string()),
|
||||
std::io::ErrorKind::IsADirectory => VfsError::IsADirectory(path.display().to_string()),
|
||||
std::io::ErrorKind::UnexpectedEof => VfsError::UnexpectedEof,
|
||||
other => VfsError::Io(format!("{}: {}", other, path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 std::fs::Metadata 构建 VfsStat
|
||||
pub fn stat_from_metadata(meta: &std::fs::Metadata, is_symlink: bool) -> VfsStat {
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let mut stat = VfsStat::new();
|
||||
stat.size = meta.len();
|
||||
stat.is_dir = meta.is_dir();
|
||||
stat.is_symlink = is_symlink;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
stat.mode = meta.permissions().mode();
|
||||
stat.uid = meta.uid();
|
||||
stat.gid = meta.gid();
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
stat.mode = if meta.is_dir() { 0o40755 } else { 0o100644 };
|
||||
}
|
||||
|
||||
if let Ok(t) = meta.accessed() {
|
||||
stat.atime = t;
|
||||
}
|
||||
if let Ok(t) = meta.modified() {
|
||||
stat.mtime = t;
|
||||
}
|
||||
|
||||
stat
|
||||
}
|
||||
|
||||
/// 构建目录条目的 long_name(类似 ls -l 格式)
|
||||
pub fn build_long_name(stat: &VfsStat, name: &str) -> String {
|
||||
let file_type = if stat.is_dir { 'd' } else { '-' };
|
||||
let perms = format_permissions(stat.mode & 0o777);
|
||||
let link_count = if stat.is_dir { 3 } else { 1 };
|
||||
let size = stat.size;
|
||||
let mtime = match stat.mtime.duration_since(std::time::UNIX_EPOCH) {
|
||||
Ok(d) => {
|
||||
let secs = d.as_secs();
|
||||
format_timestamp(secs)
|
||||
}
|
||||
Err(_) => "Jan 1 1970".to_string(),
|
||||
};
|
||||
|
||||
format!(
|
||||
"{}{} {} {} {} {} {} {}",
|
||||
file_type, perms,
|
||||
link_count,
|
||||
stat.uid,
|
||||
stat.gid,
|
||||
size,
|
||||
mtime,
|
||||
name
|
||||
)
|
||||
}
|
||||
|
||||
fn format_permissions(mode: u32) -> String {
|
||||
let rwx = |n: u32| -> String {
|
||||
let r = if n & 4 != 0 { 'r' } else { '-' };
|
||||
let w = if n & 2 != 0 { 'w' } else { '-' };
|
||||
let x = if n & 1 != 0 { 'x' } else { '-' };
|
||||
format!("{}{}{}", r, w, x)
|
||||
};
|
||||
|
||||
format!(
|
||||
"{}{}{}",
|
||||
rwx((mode >> 6) & 7),
|
||||
rwx((mode >> 3) & 7),
|
||||
rwx(mode & 7)
|
||||
)
|
||||
}
|
||||
|
||||
fn format_timestamp(secs: u64) -> String {
|
||||
let datetime = match chrono::DateTime::from_timestamp(secs as i64, 0) {
|
||||
Some(dt) => dt,
|
||||
None => return "Jan 1 1970".to_string(),
|
||||
};
|
||||
let now = chrono::Utc::now();
|
||||
if datetime.year() == now.year() {
|
||||
datetime.format("%b %e %H:%M").to_string()
|
||||
} else {
|
||||
datetime.format("%b %e %Y").to_string()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user