Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ target
# Contains mutation testing data
**/mutants.out*/

# Generated by Playwright and browser QA runs
node_modules/
playwright-report/
test-results/
output/playwright/
playwright/.cache/
screenshots/
videos/
*.webm
*.mp4
*.zip
trace.zip
*.trace
*.har

# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
Expand All @@ -25,14 +40,27 @@ app-data/
rustpost-data/
rustpost-data/**/*
!rustpost-data/.gitkeep
tmp/
tmp/**/*
db/
db/**/*
backups/
logs/
media/
media/**/*
*.sqlite3
*.sqlite3-*
*.sqlite
*.sqlite-*
*.db
*.db-*
rustpost.sqlite3*
app.sqlite3*
*.log
*.tmp
*.upload
uploads/
uploads/**/*

# macOS
.DS_Store
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rustpost"
version = "0.1.0"
version = "0.1.2"
edition = "2024"
license = "MIT"
description = "A single-binary self-hosted microblogging app"
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,17 @@ On first boot, RustPost initializes the data directory automatically:
```
rustpost-data/
├── settings.toml ← generated config with safe defaults
├── app.sqlite3 ← main database
├── db/
│ ├── rustpost.sqlite3 ← main database
│ ├── rustpost.sqlite3-wal
│ └── rustpost.sqlite3-shm
├── uploads/
│ ├── originals/
│ ├── images/
│ ├── videos/
│ └── thumbs/
├── tmp/
│ └── uploads/ ← interrupted upload staging only
├── backups/
├── logs/
└── tor/
Expand All @@ -149,12 +154,13 @@ rustpost-data/

> **Note:** All runtime paths are derived from `--data-dir` (or the executable location as fallback). RustPost does not rely on the current working directory.
> Runtime data is local operator state. Databases, uploads, logs, backups, Tor key material, and temporary upload files under `rustpost-data/` should not be committed to git.
> Existing data directories that still contain `app.sqlite3` at the data-dir root are migrated to `db/rustpost.sqlite3` on startup. If both old and new database files exist, RustPost stops with a conflict error and does not overwrite either file.

### Create Your First Admin

When `serve` starts, RustPost prints the data directory, settings path, database path, bind address, and whether an admin account exists. If no admin exists, the terminal output includes the bootstrap commands.
When `serve` starts, RustPost prints the data directory, settings path, database path, upload/media paths, log path, backup path, bind address, and whether an admin account exists. If `admin.create_admin_on_first_boot` is enabled and no admin exists, interactive startup enters a `Create admin account` step before the server begins accepting requests. If stdin is not interactive, RustPost prints the bootstrap commands instead of waiting for input.

The preferred local setup path hides the password while you type:
You can also create the first admin explicitly. The preferred local setup path hides the password while you type:

```sh
./target/release/rustpost --data-dir ./rustpost-data create-admin-interactive
Expand Down Expand Up @@ -452,7 +458,7 @@ Each archive contains `rustpost`, `rustpost-cli`, `README.md`, `LICENSE`, and op
<details>
<summary>Verified locally</summary>

- Fresh `--data-dir` boot creates `settings.toml`, `app.sqlite3`, upload roots, backup/log dirs, and Tor state dirs.
- Fresh `--data-dir` boot creates `settings.toml`, `db/rustpost.sqlite3`, upload roots, temp upload staging, backup/log dirs, and Tor state dirs.
- `rustpost-cli check` passes on a fresh data directory with `tor.enabled = false`.
- Clearnet serving on `127.0.0.1:8080` loads `/home`.
- Registration, login, post creation, replies, repost rendering, likes, bookmarks, follows, notifications, admin health, and CSRF-protected logout all work through live HTTP requests.
Expand Down
2 changes: 1 addition & 1 deletion docs/demo-preview.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,4 @@ rustpost demo password
| Tess Vale | `tess` | Video creator |
| Omar Stone | `omar` | Infrastructure/admin focused |

The generated runtime database and uploads stay under `target/debug/rustpost-demo/` and should not be committed.
The generated runtime database, uploads, temp upload staging, logs, and backups stay under `target/debug/rustpost-demo/` and should not be committed.
76 changes: 76 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "rustpost-e2e",
"private": true,
"scripts": {
"test": "playwright test",
"test:e2e": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.60.0"
}
}
45 changes: 45 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { defineConfig, devices } from '@playwright/test';

const port = Number(process.env.RUSTPOST_E2E_PORT ?? '18080');
const baseURL = process.env.RUSTPOST_E2E_BASE_URL ?? `http://127.0.0.1:${port}`;

export default defineConfig({
testDir: './tests/e2e',
outputDir: './output/playwright/test-results',
timeout: 45_000,
expect: {
timeout: 5_000,
},
fullyParallel: false,
workers: 1,
reporter: [
['list'],
['html', { outputFolder: 'output/playwright/report', open: 'never' }],
],
use: {
baseURL,
actionTimeout: 10_000,
navigationTimeout: 15_000,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
webServer: {
command: `RUSTPOST_E2E_PORT=${port} ./tests/e2e/start-rustpost.sh`,
url: baseURL,
reuseExistingServer: false,
timeout: 120_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
testIgnore: /mobile\.spec\.ts/,
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 7'] },
testMatch: /mobile\.spec\.ts/,
},
],
});
26 changes: 26 additions & 0 deletions src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,32 @@ pub async fn create_admin(
auth::register_user(pool, settings, username, password, true).await
}

pub async fn create_admin_with_display_name(
pool: &SqlitePool,
settings: &Settings,
username: &str,
password: &str,
display_name: Option<&str>,
) -> anyhow::Result<i64> {
let display_name = display_name.map(str::trim).filter(|name| !name.is_empty());
if let Some(display_name) = display_name {
crate::validation::validate_profile_text(display_name, "", settings)?;
}
let user_id = create_admin(pool, settings, username, password).await?;
if let Some(display_name) = display_name {
let display_name = display_name.to_owned();
pool.call(move |conn| {
conn.execute(
"UPDATE users SET display_name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
params![display_name, user_id],
)?;
Ok(())
})
.await?;
}
Ok(user_id)
}

pub async fn reset_admin_password(
pool: &SqlitePool,
settings: &Settings,
Expand Down
34 changes: 31 additions & 3 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ pub async fn register_user(
let hash = hash_password(password)?;
let username = username.trim().to_owned();
pool.call(move |conn| {
let existing = conn
.query_row(
"SELECT 1 FROM users WHERE normalized_username = ?",
[&normalized],
|_| Ok(()),
)
.optional()?
.is_some();
if existing {
anyhow::bail!("username is already taken");
}
conn.execute(
"INSERT INTO users (username, normalized_username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, ?)",
params![username, normalized, hash, username, i64::from(is_admin)],
Expand Down Expand Up @@ -146,13 +157,30 @@ pub async fn current_user(
.await
}

pub async fn csrf_for_cookie(pool: &SqlitePool, token: &str) -> anyhow::Result<Option<String>> {
pub async fn csrf_hashes_for_cookie(
pool: &SqlitePool,
token: &str,
) -> anyhow::Result<Option<Vec<String>>> {
let token_hash = hash_token(token);
pool.call(move |conn| {
conn.query_row(
"SELECT csrf_token_hash FROM sessions WHERE token_hash = ? AND revoked_at IS NULL",
"SELECT csrf_token_hash, previous_csrf_token_hash FROM sessions WHERE token_hash = ? AND revoked_at IS NULL",
[token_hash],
|row| row.get(0),
|row| {
let current = row.get::<_, String>(0)?;
let previous = row.get::<_, Option<String>>(1)?;
let mut hashes = Vec::with_capacity(8);
hashes.push(current);
if let Some(previous) = previous {
hashes.extend(
previous
.lines()
.filter(|hash| !hash.is_empty())
.map(ToOwned::to_owned),
);
}
Ok(hashes)
},
)
.optional()
.map_err(Into::into)
Expand Down
15 changes: 14 additions & 1 deletion src/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,17 @@ pub fn create_backup(paths: &RuntimePaths, include_tor_keys: bool) -> anyhow::Re
));
let file = File::create(&archive_path)?;
let mut builder = Builder::new(file);
append_file(&mut builder, &paths.database_path, "app.sqlite3")?;
append_file(&mut builder, &paths.database_path, "db/rustpost.sqlite3")?;
append_file(
&mut builder,
&paths.database_sidecar_path("wal"),
"db/rustpost.sqlite3-wal",
)?;
append_file(
&mut builder,
&paths.database_sidecar_path("shm"),
"db/rustpost.sqlite3-shm",
)?;
append_file(&mut builder, &paths.settings_path, "settings.toml")?;
append_dir(&mut builder, &paths.uploads_originals, "uploads/originals")?;
append_dir(&mut builder, &paths.uploads_images, "uploads/images")?;
Expand Down Expand Up @@ -128,10 +138,13 @@ mod tests {
let paths = RuntimePaths::from_data_dir(temp.path().join("data"));
paths.ensure().expect("ensure");
fs::write(&paths.database_path, "db").expect("db");
fs::write(paths.database_sidecar_path("wal"), "wal").expect("wal");
fs::write(&paths.settings_path, "settings").expect("settings");
fs::write(paths.tor_onion_service_dir.join("secret"), "key").expect("key");
let no_tor = create_backup(&paths, false).expect("backup");
let names = archive_names(&no_tor);
assert!(names.iter().any(|name| name == "db/rustpost.sqlite3"));
assert!(names.iter().any(|name| name == "db/rustpost.sqlite3-wal"));
assert!(!names.iter().any(|name| name.contains("tor/onion-service")));
let with_tor = create_backup(&paths, true).expect("backup");
let names = archive_names(&with_tor);
Expand Down
Loading
Loading