This commit is contained in:
Nicco 2024-08-25 20:59:38 +02:00 committed by GitHub
commit 49a44f6a1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1102 additions and 1560 deletions

File diff suppressed because it is too large Load Diff

View File

@ -178,7 +178,7 @@ Running `pnpm run dev` in the root folder will start the following things:
- client - client
- cli - cli
You can see the app under [localhost:1234](http://localhost:1234). You can see the app under [localhost:3000](http://localhost:3000).
> There is a Postman collection with some example requests [available in the repo](./Cryptgeon.postman_collection.json) > There is a Postman collection with some example requests [available in the repo](./Cryptgeon.postman_collection.json)

View File

@ -59,19 +59,19 @@ se usa para guardar y recuperar la nota. Después la nota es encriptada con la <
## Variables de entorno ## Variables de entorno
| Variable | Default | Descripción | | Variable | Default | Descripción |
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REDIS` | `redis://redis/` | Redis URL a la que conectarse. [Según el formato](https://docs.rs/redis/latest/redis/#connection-parameters) | | `REDIS` | `redis://redis/` | Redis URL a la que conectarse. [Según el formato](https://docs.rs/redis/latest/redis/#connection-parameters) |
| `SIZE_LIMIT` | `1 KiB` | Tamaño máximo. Valores aceptados según la [unidad byte](https://docs.rs/byte-unit/). <br> `512 MiB` es el máximo permitido. <br> El frontend mostrará ese número, incluyendo el ~35% de sobrecarga de codificación. | | `SIZE_LIMIT` | `1 KiB` | Tamaño máximo. Valores aceptados según la [unidad byte](https://docs.rs/byte-unit/). <br> `512 MiB` es el máximo permitido. <br> El frontend mostrará ese número, incluyendo el ~35% de sobrecarga de codificación. |
| `MAX_VIEWS` | `100` | Número máximo de vistas. | | `MAX_VIEWS` | `100` | Número máximo de vistas. |
| `MAX_EXPIRATION` | `360` | Tiempo máximo de expiración en minutos. | | `MAX_EXPIRATION` | `360` | Tiempo máximo de expiración en minutos. |
| `ALLOW_ADVANCED` | `true` | Permitir configuración personalizada. Si se establece en `false` todas las notas serán de una sola vista. | | `ALLOW_ADVANCED` | `true` | Permitir configuración personalizada. Si se establece en `false` todas las notas serán de una sola vista. |
| `ID_LENGTH` | `32` | Establece el tamaño en bytes de la `id` de la nota. Por defecto es de `32` bytes. Esto es util para reducir el tamaño del link. _Esta configuración no afecta el nivel de encriptación_. | | `ID_LENGTH` | `32` | Establece el tamaño en bytes de la `id` de la nota. Por defecto es de `32` bytes. Esto es util para reducir el tamaño del link. _Esta configuración no afecta el nivel de encriptación_. |
| `VERBOSITY` | `warn` | Nivel de verbosidad del backend. [Posibles valores](https://docs.rs/env_logger/latest/env_logger/#enabling-logging): `error`, `warn`, `info`, `debug`, `trace` | | `VERBOSITY` | `warn` | Nivel de verbosidad del backend. [Posibles valores](https://docs.rs/env_logger/latest/env_logger/#enabling-logging): `error`, `warn`, `info`, `debug`, `trace` |
| `THEME_IMAGE` | `""` | Imagen personalizada para reemplazar el logo. Debe ser accesible públicamente. | | `THEME_IMAGE` | `""` | Imagen personalizada para reemplazar el logo. Debe ser accesible públicamente. |
| `THEME_TEXT` | `""` | Texto personalizado para reemplazar la descripción bajo el logo. | | `THEME_TEXT` | `""` | Texto personalizado para reemplazar la descripción bajo el logo. |
| `THEME_PAGE_TITLE` | `""` | Texto personalizado para el título | | `THEME_PAGE_TITLE` | `""` | Texto personalizado para el título |
| `THEME_FAVICON` | `""` | Url personalizada para el favicon. Debe ser accesible públicamente. | | `THEME_FAVICON` | `""` | Url personalizada para el favicon. Debe ser accesible públicamente. |
## Despliegue ## Despliegue
@ -169,7 +169,7 @@ Ejecutando `pnpm run dev` en la carpeta raíz iniciará lo siguiente:
- client - client
- cli - cli
Puedes ver la app en [localhost:1234](http://localhost:1234). Puedes ver la app en [localhost:3000](http://localhost:3000).
> Existe una colección de Postman con algunas peticiones de ejemplo [disponible en el repo](./Cryptgeon.postman_collection.json) > Existe una colección de Postman con algunas peticiones de ejemplo [disponible en el repo](./Cryptgeon.postman_collection.json)

View File

@ -14,7 +14,7 @@
<a href=""><img src="./.github/lokalise.png" height="50"> <a href=""><img src="./.github/lokalise.png" height="50">
<br/> <br/>
[EN](README.md) | 简体中文 | [ES](README_ES.md) [EN](README.md) | 简体中文 | [ES](README_ES.md)
## 关于本项目 ## 关于本项目
@ -158,7 +158,7 @@ pnpm run dev
- 无热重载的 rust 后端 - 无热重载的 rust 后端
- 可热重载的客户端 - 可热重载的客户端
你可以通过 1234 端口进入该应用,即 [localhost:1234](http://localhost:1234). 你可以通过 3000 端口进入该应用,即 [localhost:3000](http://localhost:3000).
## 测试 ## 测试

View File

@ -14,7 +14,7 @@ services:
- redis - redis
restart: unless-stopped restart: unless-stopped
ports: ports:
- 1234:8000 - 3000:8000
healthcheck: healthcheck:
test: ['CMD', 'curl', '--fail', 'http://127.0.0.1:8000/api/live/'] test: ['CMD', 'curl', '--fail', 'http://127.0.0.1:8000/api/live/']

File diff suppressed because it is too large Load Diff

View File

@ -10,16 +10,18 @@ name = "cryptgeon"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
actix-web = "4" # Core
actix-files = "0.6" axum = "0.7.5"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0.208", features = ["derive"] }
tokio = { version = "1.39.3", features = ["full"] }
tower = "0.5.0"
tower-http = { version = "0.5.2", features = ["full"] }
redis = { version = "0.25.2", features = ["tls-native-tls"] }
# Utility
serde_json = "1" serde_json = "1"
lazy_static = "1" lazy_static = "1"
ring = "0.16" ring = "0.16"
bs62 = "0.1" bs62 = "0.1"
byte-unit = "4" byte-unit = "4"
dotenv = "0.15" dotenv = "0.15"
mime = "0.3"
env_logger = "0.9"
log = "0.4"
redis = { version = "0.25.2", features = ["tls-native-tls"] }

View File

@ -4,7 +4,7 @@
"scripts": { "scripts": {
"dev": "cargo watch -x 'run --bin cryptgeon'", "dev": "cargo watch -x 'run --bin cryptgeon'",
"build": "cargo build --release", "build": "cargo build --release",
"test:server": "SIZE_LIMIT=10MiB LISTEN_ADDR=0.0.0.0:1234 cargo run", "test:server": "SIZE_LIMIT=10MiB LISTEN_ADDR=0.0.0.0:3000 cargo run",
"test:prepare": "cargo build" "test:prepare": "cargo build"
} }
} }

View File

@ -1,14 +0,0 @@
use actix_web::web;
use crate::health;
use crate::note;
use crate::status;
pub fn init(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/api")
.service(note::init())
.service(status::init())
.service(health::init()),
);
}

View File

@ -1,17 +0,0 @@
use actix_files::{Files, NamedFile};
use actix_web::{web, Result};
use crate::config;
pub fn init(cfg: &mut web::ServiceConfig) {
cfg.service(
Files::new("/", config::FRONTEND_PATH.to_string())
.index_file("index.html")
.use_etag(true),
);
}
pub async fn index() -> Result<NamedFile> {
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
Ok(NamedFile::open(index)?)
}

View File

@ -1,3 +1,10 @@
mod routes; use crate::store;
use axum::http::StatusCode;
pub use routes::*; pub async fn report_health() -> (StatusCode,) {
if store::can_reach_redis() {
return (StatusCode::OK,);
} else {
return (StatusCode::SERVICE_UNAVAILABLE,);
}
}

View File

@ -1,16 +0,0 @@
use actix_web::{get, web, HttpResponse, Responder, Scope};
use crate::store;
#[get("/")]
async fn get_live() -> impl Responder {
if store::can_reach_redis() {
return HttpResponse::Ok();
} else {
return HttpResponse::ServiceUnavailable();
}
}
pub fn init() -> Scope {
web::scope("/live").service(get_live)
}

View File

@ -1,44 +1,70 @@
use actix_web::{ use axum::{
middleware::{self, Logger}, extract::Request,
web::{self}, routing::{delete, get, post},
App, HttpServer, Router, ServiceExt,
}; };
use dotenv::dotenv; use dotenv::dotenv;
use log::error; use tower::Layer;
use tower_http::{
compression::CompressionLayer,
limit::RequestBodyLimitLayer,
normalize_path::NormalizePathLayer,
services::{ServeDir, ServeFile},
};
#[macro_use] #[macro_use]
extern crate lazy_static; extern crate lazy_static;
mod api;
mod client;
mod config; mod config;
mod health; mod health;
mod note; mod note;
mod size;
mod status; mod status;
mod store; mod store;
#[actix_web::main] #[tokio::main]
async fn main() -> std::io::Result<()> { async fn main() {
dotenv().ok(); dotenv().ok();
env_logger::init_from_env(env_logger::Env::new().default_filter_or(config::VERBOSITY.as_str()));
if !store::can_reach_redis() { if !store::can_reach_redis() {
error!("cannot reach redis"); println!("cannot reach redis");
panic!("canont reach redis"); panic!("canont reach redis");
} }
return HttpServer::new(|| { let notes_routes = Router::new()
App::new() .route("/", post(note::create))
.wrap(Logger::new("\"%r\" %s %b %T")) .route("/:id", delete(note::delete))
.wrap(middleware::Compress::default()) .route("/:id", get(note::preview));
.wrap(middleware::DefaultHeaders::default()) let health_routes = Router::new().route("/live", get(health::report_health));
.configure(size::init) let status_routes = Router::new().route("/status", get(status::get_status));
.configure(api::init) let api_routes = Router::new()
.configure(client::init) .nest("/notes", notes_routes)
.default_service(web::to(client::index)) .nest("/", health_routes)
}) .nest("/", status_routes);
.bind(config::LISTEN_ADDR.to_string())?
.run() let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
.await; let serve_dir =
ServeDir::new(config::FRONTEND_PATH.to_string()).not_found_service(ServeFile::new(index));
let app = Router::new()
.nest("/api", api_routes)
.fallback_service(serve_dir)
.layer(
CompressionLayer::new()
.br(true)
.deflate(true)
.gzip(true)
.zstd(true),
)
.layer(RequestBodyLimitLayer::new(*config::LIMIT));
let app = NormalizePathLayer::trim_trailing_slash().layer(app);
let listener = tokio::net::TcpListener::bind(config::LISTEN_ADDR.to_string())
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
println!("Config {}", *config::LIMIT);
axum::serve(listener, ServiceExt::<Request>::into_make_service(app))
// axum::serve(listener, app)
.await
.unwrap();
} }

View File

@ -12,12 +12,12 @@ pub struct Note {
pub expiration: Option<u32>, pub expiration: Option<u32>,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize)]
pub struct NoteInfo { pub struct NoteInfo {
pub meta: String, pub meta: String,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize)]
pub struct NotePublic { pub struct NotePublic {
pub meta: String, pub meta: String,
pub contents: String, pub contents: String,

View File

@ -1,11 +1,16 @@
use actix_web::{delete, get, post, web, HttpResponse, Responder, Scope}; use axum::extract::Path;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::SystemTime; use std::time::SystemTime;
use crate::config; use crate::config;
use crate::note::{generate_id, Note, NoteInfo, NotePublic}; use crate::note::{generate_id, Note, NoteInfo};
use crate::store; use crate::store;
use super::NotePublic;
pub fn now() -> u32 { pub fn now() -> u32 {
SystemTime::now() SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH) .duration_since(SystemTime::UNIX_EPOCH)
@ -13,20 +18,18 @@ pub fn now() -> u32 {
.as_secs() as u32 .as_secs() as u32
} }
#[derive(Serialize, Deserialize)] #[derive(Deserialize)]
struct NotePath { pub struct OneNoteParams {
id: String, id: String,
} }
#[get("/{id}")] pub async fn preview(Path(OneNoteParams { id }): Path<OneNoteParams>) -> Response {
async fn one(path: web::Path<NotePath>) -> impl Responder { let note = store::get(&id);
let p = path.into_inner();
let note = store::get(&p.id);
match note { match note {
Ok(Some(n)) => HttpResponse::Ok().json(NoteInfo { meta: n.meta }), Ok(Some(n)) => (StatusCode::OK, Json(NoteInfo { meta: n.meta })).into_response(),
Ok(None) => HttpResponse::NotFound().finish(), Ok(None) => (StatusCode::NOT_FOUND).into_response(),
Err(e) => HttpResponse::InternalServerError().body(e.to_string()), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
} }
} }
@ -35,13 +38,16 @@ struct CreateResponse {
id: String, id: String,
} }
#[post("/")] pub async fn create(Json(mut n): Json<Note>) -> Response {
async fn create(note: web::Json<Note>) -> impl Responder { // let mut n = note.into_inner();
let mut n = note.into_inner();
let id = generate_id(); let id = generate_id();
let bad_req = HttpResponse::BadRequest().finish(); // let bad_req = HttpResponse::BadRequest().finish();
if n.views == None && n.expiration == None { if n.views == None && n.expiration == None {
return bad_req; return (
StatusCode::BAD_REQUEST,
"At least views or expiration must be set",
)
.into_response();
} }
if !*config::ALLOW_ADVANCED { if !*config::ALLOW_ADVANCED {
n.views = Some(1); n.views = Some(1);
@ -50,7 +56,7 @@ async fn create(note: web::Json<Note>) -> impl Responder {
match n.views { match n.views {
Some(v) => { Some(v) => {
if v > *config::MAX_VIEWS || v < 1 { if v > *config::MAX_VIEWS || v < 1 {
return bad_req; return (StatusCode::BAD_REQUEST, "Invalid views").into_response();
} }
n.expiration = None; // views overrides expiration n.expiration = None; // views overrides expiration
} }
@ -58,8 +64,8 @@ async fn create(note: web::Json<Note>) -> impl Responder {
} }
match n.expiration { match n.expiration {
Some(e) => { Some(e) => {
if e > *config::MAX_EXPIRATION { if e > *config::MAX_EXPIRATION || e < 1 {
return bad_req; return (StatusCode::BAD_REQUEST, "Invalid expiration").into_response();
} }
let expiration = now() + (e * 60); let expiration = now() + (e * 60);
n.expiration = Some(expiration); n.expiration = Some(expiration);
@ -67,38 +73,40 @@ async fn create(note: web::Json<Note>) -> impl Responder {
_ => {} _ => {}
} }
match store::set(&id.clone(), &n.clone()) { match store::set(&id.clone(), &n.clone()) {
Ok(_) => return HttpResponse::Ok().json(CreateResponse { id: id }), Ok(_) => (StatusCode::OK, Json(CreateResponse { id })).into_response(),
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
} }
} }
#[delete("/{id}")] pub async fn delete(Path(OneNoteParams { id }): Path<OneNoteParams>) -> Response {
async fn delete(path: web::Path<NotePath>) -> impl Responder { let note = store::get(&id);
let p = path.into_inner();
let note = store::get(&p.id);
match note { match note {
Err(e) => HttpResponse::InternalServerError().body(e.to_string()), // Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
Ok(None) => return HttpResponse::NotFound().finish(), // Ok(None) => return HttpResponse::NotFound().finish(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
Ok(None) => (StatusCode::NOT_FOUND).into_response(),
Ok(Some(note)) => { Ok(Some(note)) => {
let mut changed = note.clone(); let mut changed = note.clone();
if changed.views == None && changed.expiration == None { if changed.views == None && changed.expiration == None {
return HttpResponse::BadRequest().finish(); return (StatusCode::BAD_REQUEST).into_response();
} }
match changed.views { match changed.views {
Some(v) => { Some(v) => {
changed.views = Some(v - 1); changed.views = Some(v - 1);
let id = p.id.clone(); let id = id.clone();
if v <= 1 { if v <= 1 {
match store::del(&id) { match store::del(&id) {
Err(e) => { Err(e) => {
return HttpResponse::InternalServerError().body(e.to_string()) return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
.into_response();
} }
_ => {} _ => {}
} }
} else { } else {
match store::set(&id, &changed.clone()) { match store::set(&id, &changed.clone()) {
Err(e) => { Err(e) => {
return HttpResponse::InternalServerError().body(e.to_string()) return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
.into_response();
} }
_ => {} _ => {}
} }
@ -111,33 +119,26 @@ async fn delete(path: web::Path<NotePath>) -> impl Responder {
match changed.expiration { match changed.expiration {
Some(e) => { Some(e) => {
if e < n { if e < n {
match store::del(&p.id.clone()) { match store::del(&id.clone()) {
Ok(_) => return HttpResponse::BadRequest().finish(), Ok(_) => return (StatusCode::BAD_REQUEST).into_response(),
Err(e) => { Err(e) => {
return HttpResponse::InternalServerError().body(e.to_string()) return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
.into_response()
} }
} }
} }
} }
_ => {} _ => {}
} }
return HttpResponse::Ok().json(NotePublic {
contents: changed.contents, return (
meta: changed.meta, StatusCode::OK,
}); Json(NotePublic {
contents: changed.contents,
meta: changed.meta,
}),
)
.into_response();
} }
} }
} }
#[derive(Serialize, Deserialize)]
struct Status {
version: String,
max_size: usize,
}
pub fn init() -> Scope {
web::scope("/notes")
.service(one)
.service(create)
.service(delete)
}

View File

@ -1,12 +0,0 @@
use crate::config;
use actix_web::web;
use mime;
pub fn init(cfg: &mut web::ServiceConfig) {
let json = web::JsonConfig::default().limit(*config::LIMIT);
let plain = web::PayloadConfig::default()
.limit(*config::LIMIT)
.mimetype(mime::STAR_STAR);
// cfg.app_data(plain);
cfg.app_data(json).app_data(plain);
}

View File

@ -1,5 +1,40 @@
mod model; use crate::config;
mod routes; use axum::http::StatusCode;
use axum::Json;
use serde::Serialize;
pub use model::*; #[derive(Serialize)]
pub use routes::*; pub struct Status {
// General
pub version: String,
// Config
pub max_size: u32,
pub max_views: u32,
pub max_expiration: u32,
pub allow_advanced: bool,
pub allow_files: bool,
pub theme_new_note_notice: bool,
// Theme
pub theme_image: String,
pub theme_text: String,
pub theme_page_title: String,
pub theme_favicon: String,
}
pub async fn get_status() -> (StatusCode, Json<Status>) {
let status = Status {
version: config::VERSION.to_string(),
max_size: *config::LIMIT as u32,
max_views: *config::MAX_VIEWS,
max_expiration: *config::MAX_EXPIRATION,
allow_advanced: *config::ALLOW_ADVANCED,
allow_files: *config::ALLOW_FILES,
theme_new_note_notice: *config::THEME_NEW_NOTE_NOTICE,
theme_image: config::THEME_IMAGE.to_string(),
theme_text: config::THEME_TEXT.to_string(),
theme_page_title: config::THEME_PAGE_TITLE.to_string(),
theme_favicon: config::THEME_FAVICON.to_string(),
};
(StatusCode::OK, Json(status))
}

View File

@ -1,19 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Status {
// General
pub version: String,
// Config
pub max_size: u32,
pub max_views: u32,
pub max_expiration: u32,
pub allow_advanced: bool,
pub allow_files: bool,
pub theme_new_note_notice: bool,
// Theme
pub theme_image: String,
pub theme_text: String,
pub theme_page_title: String,
pub theme_favicon: String,
}

View File

@ -1,25 +0,0 @@
use actix_web::{get, web, HttpResponse, Responder, Scope};
use crate::config;
use crate::status::Status;
#[get("/")]
async fn get_status() -> impl Responder {
return HttpResponse::Ok().json(Status {
version: config::VERSION.to_string(),
max_size: *config::LIMIT as u32,
max_views: *config::MAX_VIEWS,
max_expiration: *config::MAX_EXPIRATION,
allow_advanced: *config::ALLOW_ADVANCED,
allow_files: *config::ALLOW_FILES,
theme_new_note_notice: *config::THEME_NEW_NOTE_NOTICE,
theme_image: config::THEME_IMAGE.to_string(),
theme_text: config::THEME_TEXT.to_string(),
theme_page_title: config::THEME_PAGE_TITLE.to_string(),
theme_favicon: config::THEME_FAVICON.to_string(),
});
}
pub fn init() -> Scope {
web::scope("/status").service(get_status)
}

View File

@ -12,5 +12,5 @@ const server = http.createServer(function (req, res) {
const target = req.url.startsWith('/api/') ? 'http://127.0.0.1:8000' : 'http://localhost:8001' const target = req.url.startsWith('/api/') ? 'http://127.0.0.1:8000' : 'http://localhost:8001'
proxy.web(req, res, { target, proxyTimeout: 250, timeout: 250 }) proxy.web(req, res, { target, proxyTimeout: 250, timeout: 250 })
}) })
server.listen(1234) server.listen(3000)
console.log('Proxy on http://localhost:1234') console.log('Proxy on http://localhost:3000')

View File

@ -3,7 +3,7 @@ import { devices, type PlaywrightTestConfig } from '@playwright/test'
const config: PlaywrightTestConfig = { const config: PlaywrightTestConfig = {
use: { use: {
video: 'retain-on-failure', video: 'retain-on-failure',
baseURL: 'http://localhost:1234', baseURL: 'http://localhost:3000',
actionTimeout: 10_000, actionTimeout: 10_000,
}, },
@ -14,7 +14,7 @@ const config: PlaywrightTestConfig = {
webServer: { webServer: {
command: 'docker compose -f docker-compose.dev.yaml up', command: 'docker compose -f docker-compose.dev.yaml up',
port: 1234, port: 3000,
reuseExistingServer: true, reuseExistingServer: true,
}, },

View File

@ -91,7 +91,7 @@ export async function CLI(...args: string[]) {
return await exec('./packages/cli/dist/cli.cjs', args, { return await exec('./packages/cli/dist/cli.cjs', args, {
env: { env: {
...process.env, ...process.env,
CRYPTGEON_SERVER: 'http://localhost:1234', CRYPTGEON_SERVER: 'http://localhost:3000',
}, },
}) })
} }

View File

@ -3,7 +3,7 @@ import { createNote } from '../../utils'
import { Files } from '../../files' import { Files } from '../../files'
test.describe('@web', () => { test.describe('@web', () => {
test.skip('to big zip', async ({ page }) => { test('to big zip', async ({ page }) => {
const files = [Files.Zip] const files = [Files.Zip]
const link = await createNote(page, { files, error: 'note is to big' }) const link = await createNote(page, { files, error: 'note is to big' })
}) })