Initial commit

This commit is contained in:
Daan Boerlage 2025-06-03 03:52:08 +02:00
commit deb3191bec
Signed by: daan
GPG key ID: FCE070E1E4956606
7 changed files with 2784 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
/test
/memos

2195
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

24
Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "memosfs"
version = "0.1.0"
edition = "2024"
[dependencies]
# Fuse
fuser = { version = "0.15", features = ["serde"] }
# Platform
nix = { version = "0.30", features = ["user"] }
# Error Handling
anyhow = "1"
# Data
serde = { version = "1", features = ["derive"] }
short-uuid = "0.2"
uuid = "1"
chrono = { version = "0.4", features = ["serde"]}
moka = { version = "0.12", features = ["sync"] }
# Web
reqwest = { version = "0.12", features = ["blocking", "json"] }

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Daan Boerlage
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

43
readme.md Normal file
View file

@ -0,0 +1,43 @@
# MemosFS
A fuse filesystem from [Memos](https://www.usememos.com/).
## Usage
`./memosfs <mountpoint> <api_base> <access_token>`
| parameter | example | description |
|----------------|---------------------------------|-------------------------------------------------------------------------|
| `mountpoint` | `./memos` | filesystem location to mount the filesystem |
| `api_base` | `https://memos.example.com/api` | base url of the API (no `/` at the end) |
| `access_token` | `eyJhbGciOiJIUz...` | an [access token](https://www.usememos.com/docs/security/access-tokens) |
## Features
- [x] List and read memos
- [ ] Editing memos
- [ ] Creating memos
- [ ] Group by tag
- [ ] Group by date
- [ ] Group by creator
- [ ] Comments
- [ ] Resources
## Notes
* This has been tested with Memos v0.24.x
* Only linux is supported
* Memos are cached for 30 seconds
## Development
**Prerequisites**
* Rust 1.87 or newer
* libfuse
**Building**
```shell
cargo build --release
```

152
src/inodes.rs Normal file
View file

@ -0,0 +1,152 @@
use short_uuid::CustomTranslator;
use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};
/// Memos is written in go and use https://github.com/lithammer/shortuuid for uid generation.
/// Considering that these are just uuids, we can get an u128 from them.
struct ShortUuidConverter {
translator: CustomTranslator,
}
impl ShortUuidConverter {
/// The go library uses a custom alphabet, so we need to use that too.
/// Taken from https://github.com/lithammer/shortuuid/blob/ffec47be5937285a5ee628d94b2dcbb8cb58e22d/alphabet.go#L12
const SHORT_UID_ALPHA: &'static str =
"23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
fn new() -> Self {
Self {
translator: CustomTranslator::new(Self::SHORT_UID_ALPHA).unwrap(),
}
}
fn try_to_u128(&self, input: &str) -> anyhow::Result<u128> {
let short = match short_uuid::ShortUuidCustom::parse_str(input, &self.translator) {
Ok(short) => short,
Err(reason) => return Err(anyhow::anyhow!("Failed to parse {}: {}", input, reason)),
};
let uid = short.to_uuid(&self.translator).unwrap();
Ok(uid.as_u128())
}
fn from_u128(&self, input: u128) -> String {
let uid = uuid::Uuid::from_u128(input);
let short = short_uuid::ShortUuidCustom::from_uuid(&uid, &self.translator);
short.to_string()
}
}
pub struct INodeManager {
nodes: HashMap<u64, u128>,
short_uuid_converter: ShortUuidConverter,
}
impl INodeManager {
pub fn new() -> Self {
Self {
nodes: HashMap::new(),
short_uuid_converter: ShortUuidConverter::new(),
}
}
pub fn get_or_create_inode(&mut self, uid: &str) -> u64 {
let num = self.short_uuid_converter.try_to_u128(uid).unwrap();
let inode = self.hash_num(num);
self.nodes.insert(inode, num);
inode
}
pub fn get_uid(&self, inode: u64) -> Option<String> {
match self.nodes.get(&inode).copied() {
None => None,
Some(num) => Some(self.short_uuid_converter.from_u128(num)),
}
}
fn hash_num(&self, num: u128) -> u64 {
let mut hasher = DefaultHasher::new();
num.hash(&mut hasher);
hasher.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// (original_uid, expected_uuid_u128, expected_inode)
const TEST_IDS: [(&str, u128, u64); 10] = [
(
"VgnhpfGXw7vHWPce6oNaVS",
206797227597459323123527397586500652966,
3913517761545603567,
),
(
"hqKG23Ds8hiZXziKorZvHH",
297564317390508405213354428528166123258,
10895052826299621117,
),
(
"JBSRgn6FcAP3s3S8WHC6Wy",
120769892440230658418002098667456834855,
925797026661051322,
),
(
"Bp7Xgaz3rs6F5e9SddHLcP",
73279780794183508306623012303071573694,
16832634417085068821,
),
(
"ELoMHpye6sqJKceTTZmyCH",
92114081947781017664946393632646945141,
3318364556962759386,
),
(
"7CppGL2gXD7meWV2GLFnLZ",
38772739364293647929273214809052070870,
17639521134799251273,
),
(
"PKK3i89cZZ3ELGjER85KND",
159155872788710468840620268141821518047,
5604479176214409706,
),
(
"bpdrRf6Y4w8X9BAL4TJZV6",
252650725998566377086795281031975766693,
6088146008880505408,
),
(
"SHtDbXtL7M7iyTnSQaMyrk",
181382667582346113605641996269296312097,
15579490772996943984,
),
(
"GA8XqyXDNFEko6nW5UFbHh",
105655915756677130982417952151348309032,
5472690243812647263,
),
];
#[test]
fn convert_short_uids() {
let converter = ShortUuidConverter::new();
for (input, expected, _) in TEST_IDS {
let num = converter.try_to_u128(input).unwrap();
assert_eq!(num, expected, "Failed to decode {}", input);
let enc = converter.from_u128(num);
assert_eq!(enc, input, "Failed to encode {}", num);
}
}
#[test]
fn inode_converter() {
let mut inode_manager = INodeManager::new();
for (input, _, expected) in TEST_IDS {
let num = inode_manager.get_or_create_inode(input);
assert_eq!(num, expected, "Failed to decode {}", input);
let enc = inode_manager.get_uid(num);
assert_eq!(enc.unwrap(), input, "Failed to encode {}", num);
}
}
}

346
src/main.rs Normal file
View file

@ -0,0 +1,346 @@
mod inodes;
use fuser::{
FileAttr, FileType, Filesystem, MountOption, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry,
Request,
};
use nix::libc::{EIO, ENOENT};
use reqwest::Method;
use serde::Deserialize;
use std::ffi::OsStr;
use std::time::{Duration, UNIX_EPOCH};
const BASE_TTL: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Deserialize)]
pub struct MemoResponse {
pub memos: Vec<Memo>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Memo {
pub name: String,
pub state: String,
pub creator: String,
pub create_time: chrono::DateTime<chrono::Utc>,
pub update_time: Option<chrono::DateTime<chrono::Utc>>,
pub display_time: Option<chrono::DateTime<chrono::Utc>>,
pub content: String,
pub visibility: String,
pub tags: Vec<String>,
pub snippet: String,
pub relations: Vec<MemoRelation>,
}
impl Memo {
pub fn into_file_attr(self, ino: u64, uid: u32, gid: u32) -> FileAttr {
let len = self.content.len() as u64;
FileAttr {
ino,
size: len,
blocks: f64::ceil(len as f64 / 512.0) as u64,
atime: self.display_time.unwrap_or(self.create_time).into(),
mtime: self.update_time.unwrap_or(self.create_time).into(),
ctime: self.update_time.unwrap_or(self.create_time).into(),
crtime: self.create_time.into(),
kind: FileType::RegularFile,
perm: 0o644,
nlink: 1,
uid,
gid,
rdev: 0,
flags: 0,
blksize: 512,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MemoRelation {
memo: MemoRelationMemo,
related_memo: MemoRelationMemo,
#[serde(rename = "type")]
relation_type: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MemoRelationMemo {
name: String,
uid: String,
snippet: String,
}
fn get_dir_attr(ino: u64, uid: u32, gid: u32) -> FileAttr {
FileAttr {
ino,
size: 0,
blocks: 0,
atime: UNIX_EPOCH,
mtime: UNIX_EPOCH,
ctime: UNIX_EPOCH,
crtime: UNIX_EPOCH,
kind: FileType::Directory,
perm: 0o755,
nlink: 2,
uid,
gid,
rdev: 0,
flags: 0,
blksize: 512,
}
}
struct MemosFS {
api_base: String,
token: String,
inode_manager: inodes::INodeManager,
cache: moka::sync::Cache<String, Memo>,
all_cache: moka::sync::Cache<String, MemoResponse>,
uid: u32,
gid: u32,
}
impl MemosFS {
fn new(api_base: &str, token: &str) -> Self {
MemosFS {
api_base: api_base.to_string(),
token: token.to_string(),
inode_manager: inodes::INodeManager::new(),
cache: moka::sync::CacheBuilder::new(500)
.time_to_live(Duration::from_secs(30))
.build(),
all_cache: moka::sync::CacheBuilder::new(5)
.time_to_live(Duration::from_secs(30))
.build(),
uid: nix::unistd::geteuid().as_raw(),
gid: nix::unistd::getegid().as_raw(),
}
}
fn get_memo_from_id(&self, id: &str) -> Option<Memo> {
if let Some(memo) = self.cache.get(id) {
return Some(memo);
}
println!("fetching {}", id);
let url = format!("{}/v1/memos/{}", self.api_base, id);
let mut req =
reqwest::blocking::Request::new(Method::GET, reqwest::Url::parse(&url).unwrap());
req.headers_mut().insert(
"Authorization",
format!("Bearer {}", self.token).parse().unwrap(),
);
let res = reqwest::blocking::Client::new().execute(req).unwrap();
match res.json::<Memo>() {
Ok(memo) => {
self.cache.insert(id.to_string(), memo.clone());
Some(memo)
}
Err(reason) => {
eprintln!("Failed to parse memo: {}", reason);
None
}
}
}
}
impl Filesystem for MemosFS {
fn lookup(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry) {
if parent != 1 {
reply.error(ENOENT);
return;
}
let name = match name.to_str() {
Some(x) => x,
None => {
reply.error(ENOENT);
return;
}
};
if name.len() != 25 {
reply.error(ENOENT);
return;
}
let name_without_ext = &name[0..22];
let ino = self.inode_manager.get_or_create_inode(name_without_ext);
match self.get_memo_from_id(&name_without_ext) {
Some(memo) => {
let attr = memo.into_file_attr(ino, self.uid, self.gid);
reply.entry(&BASE_TTL, &attr, 0)
}
None => {
reply.error(ENOENT);
}
}
}
fn getattr(&mut self, _req: &Request, ino: u64, _fh: Option<u64>, reply: ReplyAttr) {
if ino == 1 {
reply.attr(&BASE_TTL, &get_dir_attr(1, self.uid, self.gid));
return;
}
let id = match self.inode_manager.get_uid(ino) {
Some(id) => id,
None => {
reply.error(ENOENT);
return;
}
};
match self.get_memo_from_id(&id) {
Some(memo) => {
let attr = memo.into_file_attr(ino, self.uid, self.gid);
reply.attr(&BASE_TTL, &attr)
}
None => {
reply.error(ENOENT);
}
}
}
fn read(
&mut self,
_req: &Request,
ino: u64,
_fh: u64,
offset: i64,
_size: u32,
_flags: i32,
_lock: Option<u64>,
reply: ReplyData,
) {
let id = match self.inode_manager.get_uid(ino) {
Some(id) => id,
None => {
reply.error(ENOENT);
return;
}
};
match self.get_memo_from_id(&id) {
Some(memo) => {
reply.data(&memo.content.as_bytes()[offset as usize..]);
}
None => {
reply.error(ENOENT);
}
}
}
fn readdir(
&mut self,
_req: &Request,
ino: u64,
_fh: u64,
offset: i64,
mut reply: ReplyDirectory,
) {
if ino != 1 {
reply.error(ENOENT);
return;
}
if offset == 0 {
if reply.add(1, 1, FileType::Directory, ".") {
reply.ok();
return;
}
}
if offset <= 1 {
if reply.add(1, 2, FileType::Directory, "..") {
reply.ok();
return;
}
}
if offset >= 2 {
let entries = match self.all_cache.get("all") {
Some(all) => all,
None => {
println!("Fetching all memos");
let uri = format!("{}/v1/memos?page_size=100", self.api_base);
let mut req = reqwest::blocking::Request::new(
Method::GET,
reqwest::Url::parse(&uri).unwrap(),
);
req.headers_mut().insert(
"Authorization",
format!("Bearer {}", self.token).parse().unwrap(),
);
let res = match reqwest::blocking::Client::new().execute(req) {
Ok(res) => res,
Err(_) => {
reply.error(EIO);
return;
}
};
let entries = match res.json::<MemoResponse>() {
Ok(entries) => entries,
Err(_) => {
reply.error(EIO);
return;
}
};
self.all_cache.insert("all".to_string(), entries.clone());
entries
}
};
for (memo_index, entry) in entries.memos.into_iter().enumerate() {
let current_offset = (memo_index + 2) as i64;
if current_offset < offset {
continue;
}
let name = entry.name.split('/').last().unwrap();
let inode = self.inode_manager.get_or_create_inode(name);
let next_offset = current_offset + 1;
self.cache.insert(name.to_string(), entry.clone());
if reply.add(
inode,
next_offset,
FileType::RegularFile,
format!("{}.md", name),
) {
break;
}
}
}
reply.ok();
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() != 4 {
eprintln!("Usage: {} <mountpoint> <api_base> <access_token>", args[0]);
std::process::exit(1);
}
let mountpoint = &args[1];
let api_base = &args[2];
let access_token = &args[3];
let options = vec![MountOption::RO, MountOption::FSName("memosfs".to_string())];
fuser::mount2(MemosFS::new(api_base, access_token), mountpoint, &options).unwrap();
}