mirror of
https://github.com/runebaas/MemosFS.git
synced 2025-06-19 18:34:18 +02:00
Initial commit
This commit is contained in:
commit
deb3191bec
7 changed files with 2784 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
/test
|
||||
/memos
|
2195
Cargo.lock
generated
Normal file
2195
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
24
Cargo.toml
Normal file
24
Cargo.toml
Normal 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
21
LICENSE
Normal 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
43
readme.md
Normal 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
152
src/inodes.rs
Normal 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
346
src/main.rs
Normal 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();
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue