fixes and functions

This commit is contained in:
Mateusz Gruszczyński
2026-08-04 23:21:27 +02:00
parent 6fc408ddf7
commit 4b25085bb5
28 changed files with 306 additions and 330 deletions
+2
View File
@@ -32,6 +32,8 @@ RUST_LOG=rustpad=info,tower_http=warn
# Maximum upload size
UPLOAD_MAX_SIZE_MB=20
GUEST_UPLOAD_ENABLED=fakse
GUEST_UPLOAD_MAX_SIZE_MB=5
# Attachment storage: local or s3
STORAGE_DRIVER=local
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.2.34"
version = "0.2.35"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
version = "0.2.34"
version = "0.2.35"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+9 -2
View File
@@ -86,13 +86,20 @@ Publishing a protected note requires its password, but the generated public page
## Upload limit
Configure the maximum size of a single uploaded file with `UPLOAD_MAX_SIZE_MB` in `.env`, for example:
Configure the maximum size of a single uploaded file for signed-in users with `UPLOAD_MAX_SIZE_MB` in `.env`, for example:
```env
UPLOAD_MAX_SIZE_MB=50
```
The default limit is 20 MB. Restart the project with `./dev.sh` after changing it.
The default limit is 20 MB. Uploads by guests are disabled by default. Enable them deliberately and set their separate per-file limit with:
```env
GUEST_UPLOAD_ENABLED=true
GUEST_UPLOAD_MAX_SIZE_MB=5
```
Guest uploads still require read-write access to the note or workspace. Restart the project with `./dev.sh` after changing these values.
## Database selection
+10 -17
View File
@@ -16,7 +16,7 @@ pub async fn upload_pad_file(
Path(slug): Path<String>,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, ApiError> {
require_upload_permission(&state, &headers).await?;
let upload_max_size_bytes = require_upload_permission(&state, &headers).await?;
let mut password: Option<String> = None;
let mut access_token: Option<String> = None;
let mut file: Option<(String, Vec<u8>)> = None;
@@ -46,8 +46,8 @@ pub async fn upload_pad_file(
.bytes()
.await
.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
if bytes.len() > state.upload_max_size_bytes {
return Err(ApiError::payload_too_large(state.upload_max_size_bytes));
if bytes.len() > upload_max_size_bytes {
return Err(ApiError::payload_too_large(upload_max_size_bytes));
}
file = Some((filename, bytes.to_vec()));
}
@@ -255,7 +255,7 @@ pub async fn upload_note_file(
Path((workspace_slug, note_slug)): Path<(String, String)>,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, ApiError> {
require_upload_permission(&state, &headers).await?;
let upload_max_size_bytes = require_upload_permission(&state, &headers).await?;
let mut password: Option<String> = None;
let mut access_token: Option<String> = None;
let mut file: Option<(String, Vec<u8>)> = None;
@@ -285,8 +285,8 @@ pub async fn upload_note_file(
.bytes()
.await
.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
if bytes.len() > state.upload_max_size_bytes {
return Err(ApiError::payload_too_large(state.upload_max_size_bytes));
if bytes.len() > upload_max_size_bytes {
return Err(ApiError::payload_too_large(upload_max_size_bytes));
}
file = Some((filename, bytes.to_vec()));
}
@@ -507,17 +507,10 @@ pub async fn delete_note_file(
async fn require_upload_permission(
state: &SharedState,
headers: &HeaderMap,
) -> Result<(), ApiError> {
let user = crate::auth::optional_user(state, headers)
.await
.map_err(|error| ApiError::forbidden(&error.message))?;
if user.is_some() {
Ok(())
} else {
Err(ApiError::forbidden(
"Log in with read-write access to upload files.",
))
}
) -> Result<usize, ApiError> {
upload_limit_for_request(state, headers)
.await?
.ok_or_else(|| ApiError::forbidden("File uploads are disabled for guests."))
}
pub async fn download_file(
+31 -2
View File
@@ -173,6 +173,32 @@ async fn has_write_permission(
}
}
async fn upload_limit_for_request(
state: &SharedState,
headers: &HeaderMap,
) -> Result<Option<usize>, ApiError> {
if session_user(state, headers).await?.is_some() {
return Ok(Some(state.upload_max_size_bytes));
}
Ok(state
.guest_upload_enabled
.then_some(state.guest_upload_max_size_bytes))
}
async fn resource_upload_limit(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
) -> Result<Option<usize>, ApiError> {
let Some(limit) = upload_limit_for_request(state, headers).await? else {
return Ok(None);
};
Ok(has_write_permission(state, headers, kind, slug)
.await?
.then_some(limit))
}
#[derive(Debug, Serialize)]
pub struct PublishResponse {
url: Option<String>,
@@ -380,6 +406,7 @@ pub struct NoteInfo {
updated_at: String,
can_delete_files: bool,
can_upload_files: bool,
upload_max_size_bytes: Option<usize>,
global_color: Option<String>,
note_color: Option<String>,
authorship_mode: String,
@@ -996,8 +1023,9 @@ pub async fn note_info(
has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
let can_manage_authorship = workspace_owner || note_owner || password_write_access;
let can_delete_files = can_manage_authorship;
let can_upload_files = session_user(&state, &headers).await?.is_some()
&& has_write_permission(&state, &headers, "workspace", &workspace_slug).await?;
let upload_max_size_bytes =
resource_upload_limit(&state, &headers, "workspace", &workspace_slug).await?;
let can_upload_files = upload_max_size_bytes.is_some();
let can_save_editor_settings = (personal_editor_settings || can_manage_authorship)
&& has_write_permission(&state, &headers, "workspace", &workspace_slug).await?;
@@ -1024,6 +1052,7 @@ pub async fn note_info(
updated_at: db::normalize_timestamp(&note.updated_at),
can_delete_files,
can_upload_files,
upload_max_size_bytes,
global_color,
note_color,
authorship_mode: resource_editor_settings.authorship_mode,
+4 -2
View File
@@ -43,6 +43,7 @@ pub struct PadInfo {
updated_at: String,
can_delete_files: bool,
can_upload_files: bool,
upload_max_size_bytes: Option<usize>,
global_color: Option<String>,
note_color: Option<String>,
authorship_mode: String,
@@ -135,8 +136,8 @@ pub async fn pad_info(
let guest_owner = pad_creator_is_requester(&headers, &pad);
let password_write_access = has_password_write_access(&state, &headers, "pad", &slug).await?;
let can_manage_authorship = account_owner || guest_owner || password_write_access;
let can_upload_files = session_user(&state, &headers).await?.is_some()
&& has_write_permission(&state, &headers, "pad", &slug).await?;
let upload_max_size_bytes = resource_upload_limit(&state, &headers, "pad", &slug).await?;
let can_upload_files = upload_max_size_bytes.is_some();
let can_save_editor_settings = (personal_editor_settings || can_manage_authorship)
&& has_write_permission(&state, &headers, "pad", &slug).await?;
if pad.is_private == 0
@@ -159,6 +160,7 @@ pub async fn pad_info(
updated_at: db::normalize_timestamp(&pad.updated_at),
can_delete_files: can_manage_authorship,
can_upload_files,
upload_max_size_bytes,
global_color,
note_color,
authorship_mode: resource_editor_settings.authorship_mode,
+2 -4
View File
@@ -46,7 +46,7 @@ impl<B> MakeSpan<B> for PathOnlyMakeSpan {
pub fn router(
state: SharedState,
static_dir: &str,
upload_max_size_bytes: usize,
upload_body_limit_bytes: usize,
asset_cache_max_age_seconds: u64,
) -> Router {
let asset_version = state.asset_version.clone();
@@ -229,9 +229,7 @@ pub fn router(
)
.fallback(not_found)
.method_not_allowed_fallback(method_not_allowed)
.layer(DefaultBodyLimit::max(
upload_max_size_bytes.saturating_add(1024 * 1024),
))
.layer(DefaultBodyLimit::max(upload_body_limit_bytes))
.layer(TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan))
.layer(middleware::from_fn(require_csrf_token))
.layer(middleware::from_fn(apply_response_header_policy))
+1 -1
View File
@@ -81,7 +81,7 @@ pub fn render_html(
}
pub fn theme_bootstrap() -> &'static str {
r#"<script>(()=>{const key="rustpad:theme";let theme="dark";try{const saved=localStorage.getItem(key);if(saved==="light"||saved==="dark")theme=saved}catch{}const root=document.documentElement;root.dataset.theme=theme;root.style.colorScheme=theme;const meta=document.querySelector('meta[name="color-scheme"]');if(meta)meta.content=theme})();</script>"#
r#"<script>(()=>{const key="rustpad:theme";let theme=matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";try{const saved=localStorage.getItem(key);if(saved==="light"||saved==="dark")theme=saved}catch{}const root=document.documentElement;root.dataset.theme=theme;root.style.colorScheme=theme;const meta=document.querySelector('meta[name="color-scheme"]');if(meta)meta.content=theme})();</script>"#
}
pub fn stylesheet_tag(asset_version: &str, name: &str) -> String {
+73 -8
View File
@@ -55,6 +55,8 @@ pub struct Config {
pub files_dir: String,
pub storage: crate::storage::StorageConfig,
pub upload_max_size_bytes: usize,
pub guest_upload_enabled: bool,
pub guest_upload_max_size_bytes: usize,
pub asset_version: String,
pub asset_cache_max_age_seconds: u64,
pub file_cache_max_age_seconds: u64,
@@ -77,7 +79,9 @@ impl Config {
let host = values.get("APP_HOST", "127.0.0.1").parse()?;
let port = values.get("APP_PORT", "3000").parse()?;
let database_max_connections = values.get("DATABASE_MAX_CONNECTIONS", "8").parse()?;
let upload_max_size_mb: usize = values.get("UPLOAD_MAX_SIZE_MB", "20").parse()?;
let upload_max_size_mb = values.positive_u64("UPLOAD_MAX_SIZE_MB", 20)?;
let guest_upload_enabled = values.bool("GUEST_UPLOAD_ENABLED", false)?;
let guest_upload_max_size_mb = values.positive_u64("GUEST_UPLOAD_MAX_SIZE_MB", 5)?;
let anonymous_access_token_ttl_days =
values.positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?;
let user_session_ttl_days = values.positive_i64("USER_SESSION_TTL_DAYS", 3)?;
@@ -107,10 +111,6 @@ impl Config {
_ => return Err("STORAGE_DRIVER must be local or s3".into()),
};
if upload_max_size_mb == 0 {
return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into());
}
let authorization_type = AuthorizationType::from_values(&values)?;
let ldap = match authorization_type {
AuthorizationType::Local => None,
@@ -167,9 +167,12 @@ impl Config {
static_dir: values.get("STATIC_DIR", "static"),
files_dir,
storage,
upload_max_size_bytes: upload_max_size_mb
.checked_mul(1024 * 1024)
.ok_or("UPLOAD_MAX_SIZE_MB is too large")?,
upload_max_size_bytes: megabytes_to_bytes("UPLOAD_MAX_SIZE_MB", upload_max_size_mb)?,
guest_upload_enabled,
guest_upload_max_size_bytes: megabytes_to_bytes(
"GUEST_UPLOAD_MAX_SIZE_MB",
guest_upload_max_size_mb,
)?,
asset_version: env!("CARGO_PKG_VERSION").to_owned(),
asset_cache_max_age_seconds: values
.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
@@ -213,4 +216,66 @@ impl Config {
}
Ok(())
}
pub fn upload_body_limit_bytes(&self) -> usize {
multipart_body_limit_bytes(
self.upload_max_size_bytes,
self.guest_upload_enabled,
self.guest_upload_max_size_bytes,
)
}
}
fn multipart_body_limit_bytes(
user_limit_bytes: usize,
guest_upload_enabled: bool,
guest_limit_bytes: usize,
) -> usize {
let file_limit = if guest_upload_enabled {
user_limit_bytes.max(guest_limit_bytes)
} else {
user_limit_bytes
};
file_limit.saturating_add(1024 * 1024)
}
fn megabytes_to_bytes(
name: &str,
megabytes: u64,
) -> Result<usize, Box<dyn std::error::Error>> {
let bytes = megabytes
.checked_mul(1024 * 1024)
.ok_or_else(|| format!("{name} is too large"))?;
usize::try_from(bytes).map_err(|_| format!("{name} is too large").into())
}
#[cfg(test)]
mod tests {
use super::{megabytes_to_bytes, multipart_body_limit_bytes};
#[test]
fn converts_upload_megabytes_to_bytes() {
assert_eq!(megabytes_to_bytes("LIMIT", 5).unwrap(), 5 * 1024 * 1024);
}
#[test]
fn rejects_overflowing_upload_limit() {
assert!(megabytes_to_bytes("LIMIT", u64::MAX).is_err());
}
#[test]
fn multipart_limit_uses_user_limit_when_guest_uploads_are_disabled() {
assert_eq!(
multipart_body_limit_bytes(20 * 1024 * 1024, false, 50 * 1024 * 1024),
21 * 1024 * 1024
);
}
#[test]
fn multipart_limit_uses_larger_enabled_guest_limit() {
assert_eq!(
multipart_body_limit_bytes(20 * 1024 * 1024, true, 50 * 1024 * 1024),
51 * 1024 * 1024
);
}
}
+2
View File
@@ -19,6 +19,8 @@ const KNOWN_CONFIG_KEYS: &[&str] = &[
"FILES_PUBLIC_URL",
"STORAGE_DRIVER",
"UPLOAD_MAX_SIZE_MB",
"GUEST_UPLOAD_ENABLED",
"GUEST_UPLOAD_MAX_SIZE_MB",
"ASSET_CACHE_MAX_AGE_SECONDS",
"FILE_CACHE_MAX_AGE_SECONDS",
"REGISTRATION_ENABLED",
+5 -1
View File
@@ -64,6 +64,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
files_dir = %config.files_dir,
storage_driver = match &config.storage { storage::StorageConfig::Local { .. } => "local", storage::StorageConfig::S3 { .. } => "s3" },
upload_max_size_bytes = config.upload_max_size_bytes,
guest_upload_enabled = config.guest_upload_enabled,
guest_upload_max_size_bytes = config.guest_upload_max_size_bytes,
asset_cache_max_age_seconds = config.asset_cache_max_age_seconds,
file_cache_max_age_seconds = config.file_cache_max_age_seconds,
files_public_url = config.files_public_url.as_deref().unwrap_or("application origin"),
@@ -106,6 +108,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
config.asset_version.clone(),
storage,
config.upload_max_size_bytes,
config.guest_upload_enabled,
config.guest_upload_max_size_bytes,
config.file_cache_max_age_seconds,
config.files_public_url.clone(),
config.smtp.clone(),
@@ -166,7 +170,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = app::router(
state,
&config.static_dir,
config.upload_max_size_bytes,
config.upload_body_limit_bytes(),
config.asset_cache_max_age_seconds,
);
let address = SocketAddr::new(config.host, config.port);
+6
View File
@@ -99,6 +99,8 @@ pub struct AppState {
pub asset_version: String,
pub storage: crate::storage::Storage,
pub upload_max_size_bytes: usize,
pub guest_upload_enabled: bool,
pub guest_upload_max_size_bytes: usize,
pub file_cache_max_age_seconds: u64,
pub files_public_url: Option<String>,
pub smtp: Option<SmtpConfig>,
@@ -123,6 +125,8 @@ impl AppState {
asset_version: String,
storage: crate::storage::Storage,
upload_max_size_bytes: usize,
guest_upload_enabled: bool,
guest_upload_max_size_bytes: usize,
file_cache_max_age_seconds: u64,
files_public_url: Option<String>,
smtp: Option<SmtpConfig>,
@@ -140,6 +144,8 @@ impl AppState {
asset_version,
storage,
upload_max_size_bytes,
guest_upload_enabled,
guest_upload_max_size_bytes,
file_cache_max_age_seconds,
files_public_url,
smtp,
+78 -181
View File
@@ -992,11 +992,6 @@ textarea::selection {
font-size: .72rem;
}
.document-stats {
display: flex;
gap: 14px;
}
.history-panel {
position: relative;
width: 340px;
@@ -1019,15 +1014,6 @@ textarea::selection {
padding: 18px;
}
.history-help {
margin: 0;
padding: 0 18px 16px;
border-bottom: 1px solid var(--border);
color: var(--muted);
font-size: .8rem;
line-height: 1.5;
}
.icon-button {
display: grid;
place-items: center;
@@ -1199,10 +1185,6 @@ dialog::backdrop {
height: calc(100vh - 92px);
}
.toolbar-settings {
display: none;
}
.history-open .editor-layout {
grid-template-columns: minmax(0, 1fr);
}
@@ -1434,7 +1416,6 @@ dialog::backdrop {
}
.line-gutter {
overflow: hidden;
padding: 24px 8px 24px 0;
border-right: 1px solid var(--border);
@@ -1446,7 +1427,6 @@ dialog::backdrop {
.line-gutter div {
height: 1.72em;
}
.hide-editor-line-numbers .editor-shell {
@@ -1722,10 +1702,6 @@ dialog::backdrop {
flex-wrap: wrap;
}
.home-footer__separator {
color: var(--border-strong);
}
.home-footer .text-button {
font-size: inherit;
text-decoration: underline;
@@ -1761,35 +1737,6 @@ dialog::backdrop {
}
}
.line-owner-label {
position: absolute;
left: 7px;
top: 50%;
max-width: 88px;
overflow: hidden;
padding: 2px 6px;
border: 1px solid color-mix(in srgb, var(--owner) 65%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--owner) 18%, var(--surface-inset));
color: var(--text-on-owner);
font: 600 10px/1.2 system-ui, sans-serif;
text-overflow: ellipsis;
white-space: nowrap;
transform: translateY(-50%);
}
@media (min-width: 721px) {
.line-gutter {
width: 132px;
}
}
@media (max-width: 720px) {
.line-owner-label {
display: none;
}
}
/* Compact line numbers; author labels sit over the edited text, not in the gutter. */
.line-gutter {
width: 48px;
@@ -1801,10 +1748,8 @@ dialog::backdrop {
.owner-labels {
position: absolute;
right: 12px;
left: 48px;
overflow: hidden;
pointer-events: none;
}
@@ -1831,16 +1776,6 @@ dialog::backdrop {
left: 0;
}
.line-owner-label {
display: none !important;
}
@media (min-width: 721px) {
.line-gutter {
width: 48px;
}
}
@media (max-width: 720px) {
.line-gutter {
width: 42px;
@@ -1864,15 +1799,6 @@ dialog::backdrop {
height: auto;
}
.owner-line {
position: absolute;
right: 0;
left: 0;
height: var(--editor-line-height, 31px);
border-left: 3px solid var(--owner);
background: transparent;
}
.owner-label {
z-index: 1;
transform: translateY(2px);
@@ -2467,16 +2393,6 @@ dialog::backdrop {
min-width: 74px;
}
.file-delete {
border-color: var(--danger-border) !important;
background: var(--danger-subtle-bg) !important;
color: var(--danger-button-text) !important;
}
.file-delete:hover {
background: var(--danger-subtle-hover) !important;
}
@media (max-width: 760px) {
.file-actions {
flex-wrap: wrap;
@@ -2503,12 +2419,6 @@ dialog::backdrop {
font-weight: 600;
}
.workspace-actions {
display: flex;
align-items: center;
gap: 10px;
}
.workspace-password-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -2541,22 +2451,22 @@ dialog::backdrop {
.workspace-password-card__controls {
display: grid;
grid-template-columns: 106px auto;
grid-template-columns: minmax(160px, 220px) auto;
align-items: center;
gap: 6px;
}
.workspace-password-card__controls input {
width: 7vh;
height: 5vh;
min-width: 11vh;
padding: 0 1vh;
width: 100%;
min-width: 0;
min-height: 36px;
padding: 0 10px;
}
.workspace-password-card__controls button {
min-width: 7vh;
min-height: 4vh;
padding: 0 1vh;
min-width: 64px;
min-height: 36px;
padding: 0 12px;
}
.workspace-password-card>.form-message {
@@ -2694,11 +2604,6 @@ dialog::backdrop {
align-items: flex-start;
}
.workspace-actions {
align-items: stretch;
flex-direction: column-reverse;
}
.workspace-password-card {
grid-template-columns: 1fr;
}
@@ -3244,12 +3149,6 @@ dialog::backdrop {
flex: 1 0 100%;
}
.identity-panel__close {
position: absolute;
top: 14px;
right: 14px;
}
.auth-panel {
display: grid;
gap: 12px;
@@ -4810,25 +4709,6 @@ dialog::backdrop {
gap: 10px;
}
.document-owner-badge {
max-width: 50%;
overflow: hidden;
padding: 2px 8px;
border: 1px solid color-mix(in srgb, var(--owner) 58%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--owner) 14%, transparent);
color: var(--text);
font-size: .7rem;
font-weight: 600;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.document-owner-badge[hidden] {
display: none;
}
/* Per-character authorship overlay. The textarea remains the editable surface. */
.authorship-layer {
position: absolute;
@@ -5754,22 +5634,6 @@ dialog::backdrop {
}
}
.public-page-options {
display: grid;
gap: 4px;
align-content: center;
}
.public-page-options .public-task-toggle {
min-height: 24px;
}
@media (max-width: 720px) {
.public-page-options {
width: 100%;
}
}
/* Keep the whole editor surface consistent in Simple and Full modes. */
.editor-shell {
background: var(--surface-inset);
@@ -5793,7 +5657,6 @@ dialog::backdrop {
z-index: 5;
}
/* Stable editor canvas in both authorship modes. */
.pad-page .editor-column,
.pad-page .editor-shell {
@@ -6035,23 +5898,6 @@ dialog::backdrop {
gap: 7px;
}
.authorship-color-toggle {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 22px;
color: var(--muted);
font-size: 11px;
cursor: pointer;
}
.authorship-color-toggle input {
width: 28px;
height: 16px;
margin: 0;
accent-color: var(--accent);
}
.authorship-layer {
overflow: hidden;
padding: 0;
@@ -6154,7 +6000,6 @@ dialog::backdrop {
background: var(--accent);
}
.share-link-row .share-link-info {
margin-top: 8px;
justify-self: start;
@@ -6166,7 +6011,6 @@ dialog::backdrop {
white-space: pre-wrap;
}
/* Keep generated individual links below the new-link form. */
.share-link-list-wrap {
display: grid;
@@ -6473,7 +6317,6 @@ dialog::backdrop {
min-height: 34px;
}
}
/* Note editor polish: clearer actions, lighter canvas, and aligned split columns. */
@@ -6594,7 +6437,6 @@ dialog::backdrop {
text-decoration: none;
}
.resource-brand__kind {
width: fit-content;
padding-left: 0;
@@ -6774,12 +6616,6 @@ dialog::backdrop {
}
/* Share links: keep one-time URLs readable without storing plaintext tokens. */
.share-link-id {
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: .78rem;
font-weight: 600;
}
.share-link-once {
color: var(--muted-2) !important;
@@ -6828,7 +6664,7 @@ dialog::backdrop {
}
}
@media (max-width: 760px) {
@media (max-width: 760px) and (orientation: portrait) {
.pad-page .editor-toolbar {
grid-template-columns: minmax(0, 1fr) auto auto;
width: 100%;
@@ -6914,7 +6750,6 @@ dialog::backdrop {
.history-header>div,
.history-header h2,
.history-header p,
.history-help,
.history-list .empty,
.history-list .error {
min-width: 0;
@@ -6940,13 +6775,16 @@ dialog::backdrop {
}
.page-password-requirement {
margin: 6px 0 0;
margin: 2px 2px 0;
color: var(--muted);
font-size: .69rem;
font-size: .72rem;
line-height: 1.35;
}
.page-settings.needs-password .page-password-requirement {
color: var(--text-secondary);
}
.page-password-requirement[hidden] {
display: none;
}
@@ -6978,7 +6816,7 @@ dialog::backdrop {
.page-password-inline__controls input {
width: 100%;
min-width: 0;
height: 50%;
min-height: 34px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 6px;
@@ -6998,8 +6836,8 @@ dialog::backdrop {
}
.page-password-inline__save {
min-width: 1vh;
height: 3vh;
min-width: 56px;
min-height: 34px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 6px;
@@ -7022,4 +6860,63 @@ dialog::backdrop {
.page-password-inline .error {
margin: 0;
font-size: .68rem;
}
}
/* Mobile upload action shown directly below the formatting controls. */
.mobile-upload-button {
display: none;
}
@media (max-width: 760px) {
.pad-page .toolbar-group {
grid-column: 1;
grid-row: 1;
}
.pad-page .view-switch {
grid-column: 2;
grid-row: 1;
}
.pad-page .mobile-upload-button {
display: inline-flex;
grid-column: 1 / -1;
grid-row: 2;
width: 100%;
min-height: 36px;
align-items: center;
justify-content: center;
gap: 7px;
padding: 7px 12px;
border: 1px solid var(--toolbar-action-border);
border-radius: 8px;
background: var(--toolbar-action-bg);
color: var(--toolbar-action-text);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.pad-page .mobile-upload-button:hover {
border-color: color-mix(in srgb, var(--accent) 48%, var(--toolbar-action-border));
background: var(--toolbar-action-hover);
}
.pad-page .mobile-upload-button:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
}
@media (max-width: 760px) and (orientation: portrait) {
.pad-page #mode-toggle {
grid-column: 2;
grid-row: 1;
}
.pad-page .view-switch {
grid-column: 3;
grid-row: 1;
}
}
+18 -16
View File
@@ -43,17 +43,7 @@
<summary class="secondary-button" aria-label="Page options"><span class="page-settings__label"><span
class="page-settings__status" aria-hidden="true"></span>Page</span><span
class="page-settings__chevron" aria-hidden="true">▾</span></summary>
<div class="page-settings-menu"><button id="publish-page" class="page-settings-action"
type="button"><span>Open page</span><small>Copy its link and open it in a new
tab</small></button>
<div class="page-settings-divider" role="separator"></div><label class="public-task-toggle"
title="Enable or disable the published page"><input id="public-page-enabled"
type="checkbox"> Enable Page</label><label class="public-task-toggle"
title="Allow visitors to update task checkboxes on the published page"><input
id="public-task-updates" type="checkbox"> Editable tasks</label><label
class="public-task-toggle"
title="Allow the published page to open without the resource password or private access"><input
id="unprotect-public-page" type="checkbox"> Unprotect Page</label>
<div class="page-settings-menu">
<p id="page-password-requirement" class="page-password-requirement" hidden>Access to page
options requires a password-protected note.</p>
<form id="set-page-password-form" class="page-password-inline" hidden>
@@ -65,8 +55,18 @@
minlength="8" maxlength="128" autocomplete="new-password" placeholder="Min. 8 chars"
aria-describedby="set-page-password-help" required><button type="submit"
class="page-password-inline__save">Set</button></div>
<small id="set-page-password-error" class="error"></small>
<small id="set-page-password-error" class="error" role="alert" aria-live="polite"></small>
</form>
<button id="publish-page" class="page-settings-action" type="button"><span>Open
page</span><small>Copy its link and open it in a new tab</small></button>
<div class="page-settings-divider" role="separator"></div><label class="public-task-toggle"
title="Enable or disable the published page"><input id="public-page-enabled"
type="checkbox"> Enable Page</label><label class="public-task-toggle"
title="Allow visitors to update task checkboxes on the published page"><input
id="public-task-updates" type="checkbox"> Editable tasks</label><label
class="public-task-toggle"
title="Allow the published page to open without the resource password or private access"><input
id="unprotect-public-page" type="checkbox"> Unprotect Page</label>
</div>
</details><button id="files-button" class="secondary-button">Files</button><button id="delete-note"
class="secondary-button danger-button" hidden>Delete</button><button id="history-button"
@@ -116,6 +116,8 @@
data-format="horizontal-rule">Horizontal rule</button></div>
</details>
</div>
<button id="mobile-upload-button" class="mobile-upload-button" type="button"
title="Upload an image or file"><span aria-hidden="true">📎</span> Upload file</button>
<div class="editor-controls"><label>Font<select id="font-family">
<option value="mono">Mono</option>
<option value="system">System</option>
@@ -139,9 +141,9 @@
aria-pressed="true" aria-label="Markdown" title="Markdown"><span
class="control-label-full">Markdown</span><span class="control-label-short"
aria-hidden="true">M</span></button>
<div class="view-switch" aria-label="Editor view"><button data-view="edit" aria-label="Edit"
title="Edit"><span class="control-label-full">Edit</span><span class="control-label-short"
aria-hidden="true">E</span></button><button data-view="split" class="active"
<div class="view-switch" aria-label="Editor view"><button data-view="edit" class="active"
aria-label="Edit" title="Edit"><span class="control-label-full">Edit</span><span
class="control-label-short" aria-hidden="true">E</span></button><button data-view="split"
aria-label="Split" title="Split"><span class="control-label-full">Split</span><span
class="control-label-short" aria-hidden="true">S</span></button><button data-view="preview"
aria-label="Preview" title="Preview"><span class="control-label-full">Preview</span><span
@@ -154,7 +156,7 @@
interrupted</strong><span id="connection-notice-message">Trying to reconnect
automatically.</span></span>
</div>
<div id="editor-workspace" class="workspace view-split">
<div id="editor-workspace" class="workspace view-edit">
<div class="editor-column">
<div class="column-label editor-column-label"><span>Editor</span>
<div class="authorship-controls"><label class="switch-control authorship-colors-switch"
+4 -4
View File
@@ -85,9 +85,9 @@ async function clearSessionIfInvalid() {
}
}
function validateUploadSize(body) {
function validateUploadSize(body, configuredMaxBytes) {
if (!(body instanceof FormData)) return;
const maxBytes = Number(window.__RUSTPAD_CONFIG__?.uploadMaxSizeBytes || 0);
const maxBytes = Number(configuredMaxBytes ?? window.__RUSTPAD_CONFIG__?.uploadMaxSizeBytes ?? 0);
if (!Number.isFinite(maxBytes) || maxBytes <= 0) return;
for (const value of body.values()) {
if (value instanceof File && value.size > maxBytes) {
@@ -123,7 +123,7 @@ function formDataFileSize(body) {
}
export async function api(path, options = {}) {
validateUploadSize(options.body);
validateUploadSize(options.body, options.uploadMaxSizeBytes);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12000);
try {
@@ -166,7 +166,7 @@ export async function api(path, options = {}) {
}
export function uploadWithProgress(path, options = {}) {
validateUploadSize(options.body);
validateUploadSize(options.body, options.uploadMaxSizeBytes);
const method = options.method || "POST";
const fallbackTotal = formDataFileSize(options.body);
const stallTimeoutMs = Number(options.stallTimeoutMs) > 0 ? Number(options.stallTimeoutMs) : 90000;
-19
View File
@@ -161,25 +161,6 @@ export function mapSelectionThroughEdit(previousText, nextText, start, end = sta
end: Math.max(0, Math.min(nextText.length, map(end))),
};
}
export function lineOwners(content, model) {
const starts = [0];
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1);
return starts.map((start, index) => {
const end = index + 1 < starts.length ? starts[index + 1] : content.length;
const totals = new Map();
const representatives = new Map();
for (const span of model?.spans || []) {
const overlap = Math.max(0, Math.min(end, span.end) - Math.max(start, span.start));
if (!overlap) continue;
const identity = ownerIdentity(span.owner);
totals.set(identity, (totals.get(identity) || 0) + overlap);
representatives.set(identity, span.owner);
}
const identity = [...totals.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
return identity ? representatives.get(identity) : "";
});
}
export function syncAuthorshipLayer(layer, editor) {
if (!layer || !editor) return;
const canvas = layer.querySelector(".authorship-canvas");
-25
View File
@@ -9,10 +9,6 @@
import { parseAuthorship, serializeAuthorship } from "@rustpad/authorship";
function utf16Length(value) {
return String(value || "").length;
}
function normalizeOwnerSpans(spans, length) {
const result = [];
const sorted = [...(Array.isArray(spans) ? spans : [])].sort((left, right) => (Number(left?.start) || 0) - (Number(right?.start) || 0) || (Number(left?.end) || 0) - (Number(right?.end) || 0));
@@ -244,23 +240,6 @@ export function transformOperations(leftOperation, rightOperation, leftBeforeRig
return [{ components: leftPrime }, { components: rightPrime }];
}
export function applyOperation(text, operation) {
text = String(text || "");
operation = normalizeOperation(operation);
if (operationBaseLength(operation) !== text.length) throw new Error("Operation base length does not match document");
let cursor = 0;
let result = "";
for (const component of operation.components) {
if (component.kind === "retain") {
result += text.slice(cursor, cursor + component.count);
cursor += component.count;
} else if (component.kind === "delete") cursor += component.count;
else result += component.text;
}
if (cursor !== text.length) throw new Error("Operation did not consume the whole document");
return result;
}
function copyRetainedSpans(target, spans, sourceStart, length, outputStart) {
const sourceEnd = sourceStart + length;
for (const span of spans || []) {
@@ -303,10 +282,6 @@ export function applyOperationToDocument(content, ownerMap, operation, ownerRepl
return { content: nextContent, ownerMap: serializeAuthorship(model, nextContent.length), authorship: model };
}
export function operationEquals(left, right) {
return JSON.stringify(normalizeOperation(left)) === JSON.stringify(normalizeOperation(right));
}
export function compareOperationKeys(left, right) {
const leftClient = String(left?.clientId || left?.client_id || "");
const rightClient = String(right?.clientId || right?.client_id || "");
+1 -1
View File
@@ -65,7 +65,7 @@ document.querySelector("#pad-form").addEventListener("submit", async (event) =>
if (password.value) payload.password = password.value;
const result = await api("/api/pads", { method: "POST", headers: authHeaders(), body: JSON.stringify(payload) });
if (password.value) { const grant = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "pad", slug: result.slug, password: password.value }) }); setAccessToken("pad", result.slug, grant.granted); }
window.location.assign(safeAppUrl(`${result.url}?view=split&mode=markdown`));
window.location.assign(safeAppUrl(result.url));
} catch (requestError) {
error.textContent = requestError.message;
} finally {
-20
View File
@@ -100,26 +100,6 @@ export function buildImageAlias(options = {}) {
return `[${kind}=${filename},${parts.join(",")}]`;
}
export function updateImageAliasInLine(line, aliasIndex, patch = {}) {
const source = String(line || "");
const targetIndex = Number(aliasIndex);
if (!Number.isInteger(targetIndex) || targetIndex < 0) return null;
const codeRanges = inlineCodeRanges(source);
let index = 0;
let changed = false;
const value = source.replace(imageAliasPattern(), (match, ...args) => {
const offset = args.at(-2);
if (isInsideRange(offset, codeRanges) || index++ !== targetIndex) return match;
const parsed = parseImageAlias(match);
if (!parsed) return match;
changed = true;
return buildImageAlias({ ...parsed, ...patch });
});
return changed ? value : null;
}
export function updateImageAliasInLineBySource(line, aliasSource, occurrence = 0, patch = {}) {
const source = String(line || "");
const target = String(aliasSource || "");
+23 -6
View File
@@ -119,7 +119,7 @@ export function startNoteEditor(adapter) {
redo() { return this.move(1); },
};
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
const singlePaneQuery = window.matchMedia("(max-width: 760px)");
const singlePaneQuery = window.matchMedia("(max-width: 760px) and (orientation: landscape)");
let compactView = uiState.view === "preview" ? "preview" : "edit";
let renderedView = singlePaneQuery.matches ? compactView : uiState.view;
let refreshFilesForAliases = () => { };
@@ -1241,6 +1241,7 @@ export function startNoteEditor(adapter) {
const { loadFiles } = bindNoteFiles({
editor, toast, getAccessToken: () => accessToken,
getUploadMaxSize: () => Number(info?.upload_max_size_bytes) || 0,
canDelete: () => Boolean(info?.can_delete_files),
canUpload: () => Boolean(info?.can_upload_files),
canEdit: canEditDocument,
@@ -1374,6 +1375,7 @@ export function startNoteEditor(adapter) {
}
async function initialize() {
applyUi();
try {
const session = await validateCurrentSession();
nickname = session?.nickname || getNickname();
@@ -1426,7 +1428,10 @@ export function startNoteEditor(adapter) {
setHeaderMenuOpen(!headerActions.classList.contains("is-open"));
});
headerActions.addEventListener("click", event => {
if (compactLayoutQuery.matches && event.target.closest("button")) setHeaderMenuOpen(false);
const button = event.target.closest("button");
if (compactLayoutQuery.matches && button && !button.closest(".page-settings-menu")) {
setHeaderMenuOpen(false);
}
});
document.addEventListener("click", event => {
if (!event.target.closest(".header-navigation")) setHeaderMenuOpen(false);
@@ -1860,6 +1865,7 @@ export function startNoteEditor(adapter) {
function updatePageControls() {
const passwordProtected = Boolean(info?.protected);
const workspacePassword = adapter.passwordScope === "workspace";
const canSetPassword = !passwordProtected && Boolean(adapter.setPassword) && Boolean(info?.can_set_password);
if (!passwordProtected) {
publicPageEnabled.checked = false;
unprotectPublicPage.checked = false;
@@ -1868,12 +1874,15 @@ export function startNoteEditor(adapter) {
? "Access to page options requires a password-protected workspace."
: "Access to page options requires a password-protected note.";
if (pagePasswordRequirement) {
pagePasswordRequirement.textContent = requirementText;
pagePasswordRequirement.textContent = canSetPassword
? `Set a ${workspacePassword ? "workspace" : "note"} password here to enable Page publishing.`
: requirementText;
pagePasswordRequirement.hidden = passwordProtected;
}
if (setPagePasswordLabel) setPagePasswordLabel.textContent = workspacePassword ? "Set workspace password" : "Set password";
if (setPagePasswordHelp) setPagePasswordHelp.textContent = workspacePassword ? "Protects the entire workspace. Minimum 8 characters." : "Minimum 8 characters.";
const canSetPassword = !passwordProtected && Boolean(adapter.setPassword) && Boolean(info?.can_set_password);
if (setPagePasswordLabel) setPagePasswordLabel.textContent = workspacePassword ? "Workspace password" : "Note password";
if (setPagePasswordHelp) setPagePasswordHelp.textContent = workspacePassword
? "At least 8 characters. It protects the workspace and all notes."
: "At least 8 characters. It also protects editing access.";
setPagePasswordForm.hidden = !canSetPassword;
const enabled = passwordProtected && publicPageEnabled.checked;
publicPageEnabled.disabled = !passwordProtected;
@@ -1885,6 +1894,7 @@ export function startNoteEditor(adapter) {
"false",
);
pageSettings?.classList.toggle("is-enabled", enabled);
pageSettings?.classList.toggle("needs-password", canSetPassword);
pageSettings?.querySelector("summary")?.setAttribute(
"title",
!passwordProtected
@@ -1896,6 +1906,11 @@ export function startNoteEditor(adapter) {
: "Published page disabled",
);
}
pageSettings?.addEventListener("toggle", () => {
if (pageSettings.open && !setPagePasswordForm.hidden) {
requestAnimationFrame(() => setPagePasswordInput.focus());
}
});
document.addEventListener("pointerdown", event => {
const target = event.target instanceof Element ? event.target : null;
if (pageSettings?.open && !target?.closest(".page-settings")) pageSettings.open = false;
@@ -1929,6 +1944,8 @@ export function startNoteEditor(adapter) {
loadFiles();
connect();
toast("Password set. Page options are now available.");
pageSettings.open = true;
requestAnimationFrame(() => publicPageEnabled.focus());
} catch (error) {
setPagePasswordError.textContent = error.message;
} finally {
+9 -5
View File
@@ -70,7 +70,7 @@ function safeAttachmentUrl(value) {
: safePublicUrl(raw, { allowMailto: false });
}
export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, canUpload, canEdit = () => true, toast, onFilesChanged = () => { } }) {
export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxSize = () => 0, canDelete, canUpload, canEdit = () => true, toast, onFilesChanged = () => { } }) {
const dialog = document.querySelector("#files-dialog");
const list = document.querySelector("#files-list");
const input = document.querySelector("#file-input");
@@ -126,6 +126,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
method: "POST",
body: form,
headers: {},
uploadMaxSizeBytes: getUploadMaxSize(),
onProgress: progress => uploadToast.update(progress),
});
if (completed) return;
@@ -143,13 +144,16 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
await run();
}
document.querySelector("#upload-button").addEventListener("click", () => {
function requestUpload() {
if (!canUpload() || !canEdit()) {
toast("Log in with read-write access to upload files.");
toast("You need read-write access and upload permission to upload files.");
return;
}
input.click();
});
}
document.querySelector("#upload-button")?.addEventListener("click", requestUpload);
document.querySelector("#mobile-upload-button")?.addEventListener("click", requestUpload);
input.addEventListener("change", async event => {
let file = event.target.files[0];
@@ -175,7 +179,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
if (!files.length) return;
event.preventDefault();
if (!canUpload() || !canEdit()) {
toast("Log in with read-write access to paste files.");
toast("You need read-write access and upload permission to paste files.");
return;
}
-3
View File
@@ -30,6 +30,3 @@ export function safePublicUrl(value, { allowMailto = true } = {}) {
}
}
export function safeHexColor(value, fallback = "#64748b") {
return /^#[0-9a-f]{6}$/i.test(String(value || "")) ? String(value) : fallback;
}
-4
View File
@@ -89,7 +89,3 @@ export function clearAuthSession() {
sessionStorage.removeItem(NICKNAME_KEY);
setNicknameCookie("");
}
export async function resolveIdentity(api, nickname) {
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname }) });
setNickname(result.nickname); return result;
}
+15 -3
View File
@@ -7,11 +7,15 @@
* See LICENSE file in repository root for details.
*/
const STORAGE_KEY = "rustpad:theme";
const DEFAULT_THEME = "dark";
const THEMES = new Set(["dark", "light"]);
const systemThemeQuery = window.matchMedia("(prefers-color-scheme: light)");
function systemTheme() {
return systemThemeQuery.matches ? "light" : "dark";
}
function normalizeTheme(value) {
return THEMES.has(value) ? value : DEFAULT_THEME;
return THEMES.has(value) ? value : systemTheme();
}
function updateBrowserChrome(theme) {
@@ -42,5 +46,13 @@ export function applySessionTheme(session) {
}
window.addEventListener("storage", event => {
if (event.key === STORAGE_KEY && event.newValue) applyTheme(event.newValue, { persist: false });
if (event.key !== STORAGE_KEY) return;
applyTheme(THEMES.has(event.newValue) ? event.newValue : systemTheme(), { persist: false });
});
systemThemeQuery.addEventListener("change", () => {
try {
if (THEMES.has(localStorage.getItem(STORAGE_KEY))) return;
} catch { }
applyTheme(systemTheme(), { persist: false });
});
+6 -1
View File
@@ -10,10 +10,15 @@
const VIEWS = new Set(["edit", "split", "preview"]);
const MODES = new Set(["markdown", "text"]);
function defaultEditorView() {
return window.matchMedia("(max-width: 760px)").matches ? "edit" : "split";
}
export function readEditorState() {
const params = new URLSearchParams(window.location.search);
const requestedView = params.get("view");
return {
view: VIEWS.has(params.get("view")) ? params.get("view") : "split",
view: VIEWS.has(requestedView) ? requestedView : defaultEditorView(),
mode: MODES.has(params.get("mode")) ? params.get("mode") : "markdown",
};
}
+3 -3
View File
@@ -93,7 +93,7 @@ function renderNotes(notes = notesCache) {
if (notesView === "table") {
notesList.innerHTML = `<div class="notes-table-scroll"><table><thead><tr><th>Name</th><th>Created by</th><th>Participants</th><th>Files</th><th>Revisions</th><th>Status</th><th>Updated</th><th class="notes-table-actions">Actions</th></tr></thead><tbody>${notes.map(note => `
<tr>
<td><a class="note-table-link" href="${escapeHtml(safeAppUrl(`${note.url}?view=split&mode=markdown`))}">${escapeHtml(note.title)}</a></td>
<td><a class="note-table-link" href="${escapeHtml(safeAppUrl(note.url))}">${escapeHtml(note.title)}</a></td>
<td class="note-author">${escapeHtml(note.created_by || "Unknown")}</td>
<td>${Number(note.participant_count) || 0}</td>
<td>${Number(note.file_count) || 0} <span class="note-status">(${formatBytes(note.file_size_bytes)})</span></td>
@@ -106,7 +106,7 @@ function renderNotes(notes = notesCache) {
}
notesList.innerHTML = notes.map(note => `
<article class="note-card-wrap">
<a class="note-card" href="${escapeHtml(safeAppUrl(`${note.url}?view=split&mode=markdown`))}">
<a class="note-card" href="${escapeHtml(safeAppUrl(note.url))}">
<div class="note-card-title"><h3>${escapeHtml(note.title)}</h3>${note.protected ? '<span class="protect-badge">Protected</span>' : ''}</div>
<div class="note-card-meta"><span>Created by: ${escapeHtml(note.created_by || "Unknown")}</span>${noteStats(note)}<span>Updated: ${formatDate(note.updated_at)}</span></div>
</a>
@@ -218,7 +218,7 @@ document.querySelector("#note-form").addEventListener("submit", async e => {
method: "POST",
body: JSON.stringify({ name: document.querySelector("#note-name").value, access_token: accessToken || null, protect: document.querySelector("#note-protect").checked, created_by: nickname || null })
});
location.assign(safeAppUrl(`${note.url}?view=split&mode=markdown`));
location.assign(safeAppUrl(note.url));
} catch (err) { error.textContent = err.message; }
});
notesList.addEventListener("click", async event => {
+2
View File
@@ -13,6 +13,8 @@ files_dir: /var/lib/rustpad/files
# files_public_url: files.note.example.org
storage_driver: local
upload_max_size_mb: 20
guest_upload_enabled: false
guest_upload_max_size_mb: 5
asset_cache_max_age_seconds: 600
file_cache_max_age_seconds: 600