Compare commits

...

5 commits
v0.1.0 ... main

Author SHA1 Message Date
089ce4d973 Publish the app port on all interfaces, not just loopback
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m42s
2026-08-23 23:18:38 +02:00
6fee71eec1 3 - Add README for self-hosters
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m51s
2026-08-23 21:58:27 +02:00
edfa58579b Add readme
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
2026-08-23 21:52:51 +02:00
b11bcc3f0b 5 - Cache anagram results, keyed on the dictionary contents 2026-08-23 21:46:14 +02:00
24fa771d39 4 - Fix mobile layout of header and footer
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m4s
2026-08-23 21:37:57 +02:00
8 changed files with 245 additions and 26 deletions

View file

@ -4,6 +4,9 @@ APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost:8000 APP_URL=http://localhost:8000
# Host port the app is published on (self-hosting only).
APP_PORT=8000
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US APP_FAKER_LOCALE=en_US

104
README.md Normal file
View file

@ -0,0 +1,104 @@
# Anagram Finder
[![License](https://img.shields.io/badge/license-AGPL--3.0-blue)](LICENSE)
[![Release](https://img.shields.io/gitea/v/release/anagram-finder/web?gitea_url=https%3A%2F%2Fforge.lvl0.xyz)](https://forge.lvl0.xyz/anagram-finder/web/releases)
Type a handful of letters, get every word you can build from them. Self-hosted, no accounts,
no tracking — one input and a list of results.
![Anagram Finder](docs/screenshots/anagram-finder.png)
## Features
- Finds every dictionary word that fits your letters, longest first
- 466,000-word English dictionary, bundled — no external lookups
- Results are cached, so a repeated search is instant
- Shareable result URLs (`/find/eamstoxil`)
- Works without JavaScript; the whole thing is one form and one page
- Terminal-styled, dark by default, readable on a phone
## Self-hosting
Images are published to `forge.lvl0.xyz/anagram-finder/web`. Grab
[`compose.yaml`](compose.yaml), set the three required variables, and start it:
```bash
mkdir anagram-finder && cd anagram-finder
curl -O https://forge.lvl0.xyz/anagram-finder/web/raw/branch/main/compose.yaml
cat > .env <<EOF
APP_KEY=base64:$(openssl rand -base64 32)
APP_URL=http://localhost:8000
DB_PASSWORD=$(openssl rand -hex 16)
DB_ROOT_PASSWORD=$(openssl rand -hex 16)
EOF
docker compose up -d
```
The app is then on <http://localhost:8000>. Put a reverse proxy in front of it for anything
public-facing, and set `APP_URL` to the address people will actually use.
`compose.yaml` pins `:latest`. Check [Releases](https://forge.lvl0.xyz/anagram-finder/web/releases)
and pin a version tag if you would rather upgrade deliberately.
### Configuration
The snippet above generates everything required. In full:
| Variable | Description |
|---|---|
| `APP_KEY` | Encryption key, `base64:` prefixed |
| `APP_URL` | The address people will actually use |
| `DB_PASSWORD` | Database password |
| `DB_ROOT_PASSWORD` | MariaDB root password |
`APP_PORT` moves the published port (default `8000`) — set `APP_URL` to match if you change it.
`DB_DATABASE` and `DB_USERNAME` both default to `anagram`. Anything else you might want to
override is listed in [`.env.example`](.env.example).
Migrations run automatically on first boot.
## Development
```bash
git clone https://forge.lvl0.xyz/anagram-finder/web.git
cd web
nix-shell
```
The shell prints the available commands and offers to start the containers.
| Command | Description |
|---|---|
| `dev-up` | Start the development environment |
| `dev-down` | Stop it (`-v` also drops the database volume) |
| `dev-rebuild` | Rebuild images and restart |
| `dev-shell` | Enter the app container |
| `dev-artisan <cmd>` | Run an artisan command |
| `dev-composer <cmd>` | Run composer in the container |
| `dev-test` | Run the test suite |
| `dev-db` | MariaDB client on the dev database |
| `dev-logs` / `dev-logs-db` | Follow the app or database log |
| Service | URL |
|---|---|
| App | http://localhost:8001 |
| Vite | http://localhost:5174 |
| MariaDB | localhost:3308 |
## Related
The matching logic and the dictionary live in
[anagram-finder/core](https://forge.lvl0.xyz/anagram-finder/core), shared with
[anagram-finder/tui](https://forge.lvl0.xyz/anagram-finder/tui), a terminal client.
Styling comes from [lvl0/ui](https://forge.lvl0.xyz/lvl0/ui).
## Contributing
Issues and pull requests are welcome at
[Issues](https://forge.lvl0.xyz/anagram-finder/web/issues).
## License
Anagram Finder is free software, licensed under the [GNU AGPL-3.0](LICENSE).

View file

@ -3,13 +3,15 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\FindAnagramWordsRequest; use App\Http\Requests\FindAnagramWordsRequest;
use App\Services\AnagramSearch;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator;
use Illuminate\View\View; use Illuminate\View\View;
use Lvl0\AnagramFinder\Core\Matcher;
class AnagramController extends Controller class AnagramController extends Controller
{ {
public function __construct(private readonly AnagramSearch $search) {}
public function index(): View public function index(): View
{ {
return view('index'); return view('index');
@ -17,16 +19,30 @@ public function index(): View
public function find(FindAnagramWordsRequest $request): RedirectResponse public function find(FindAnagramWordsRequest $request): RedirectResponse
{ {
$anagram = $request->get('anagram'); return redirect()->route('results', $request->validated('anagram'));
return redirect()->route('results', $anagram);
} }
public function results(Request $request) public function results(string $anagram): View|RedirectResponse
{ {
$anagram = $request->anagram; // The URL segment bypasses the form request, so it gets the same rules.
$matches = Matcher::findWords($anagram); $anagram = strtolower($anagram);
return view('index', ['anagram' => $anagram, 'matches' => $matches]); $validator = Validator::make(
['anagram' => $anagram],
(new FindAnagramWordsRequest)->rules(),
(new FindAnagramWordsRequest)->messages(),
);
if ($validator->fails()) {
return redirect()
->route('home')
->withErrors($validator)
->withInput(['anagram' => $anagram]);
}
return view('index', [
'anagram' => $anagram,
'matches' => $this->search->find($anagram),
]);
} }
} }

View file

@ -7,23 +7,35 @@
class FindAnagramWordsRequest extends FormRequest class FindAnagramWordsRequest extends FormRequest
{ {
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool public function authorize(): bool
{ {
return true; return true;
} }
/** /**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string> * @return array<string, ValidationRule|array<mixed>|string>
*/ */
public function rules(): array public function rules(): array
{ {
return [ return [
// 'anagram' => ['required', 'string', 'alpha:ascii', 'min:2', 'max:24'],
]; ];
} }
public function messages(): array
{
return [
'anagram.alpha' => 'Letters only — no digits, spaces or punctuation.',
'anagram.max' => 'That is longer than any word in the dictionary.',
];
}
// The dictionary is lowercase and Matcher::check compares raw characters,
// so an uppercase anagram would silently return nothing.
protected function prepareForValidation(): void
{
$this->merge([
'anagram' => strtolower((string) $this->input('anagram', '')),
]);
}
} }

View file

@ -0,0 +1,84 @@
<?php
namespace App\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Lvl0\AnagramFinder\Core\Matcher;
/**
* Caches anagram lookups. A search scans the whole dictionary 466k words
* and the result is fixed for a given (letters, minLength, dictionary), so it
* only ever needs computing once.
*/
class AnagramSearch
{
private const DICTIONARY = __DIR__ . '/../../vendor/anagram-finder/core/assets/words.txt';
public function find(string $anagram, int $minLength = 3): Collection
{
$key = $this->key($anagram, $minLength);
// Cache a plain array: a serialised Collection comes back as
// __PHP_Incomplete_Class through the database store.
$words = Cache::rememberForever(
$key,
fn () => Matcher::findWords($this->normalise($anagram), $minLength)
->values()
->all(),
);
return new Collection($words);
}
/**
* Entries live forever; the dictionary hash in the key is the invalidation
* mechanism. Replacing words.txt makes every old key unreachable rather
* than stale, so nothing has to be flushed by hand.
*/
private function key(string $anagram, int $minLength): string
{
return sprintf(
'anagram:%s:%d:%s',
$this->dictionaryHash(),
$minLength,
$this->normalise($anagram),
);
}
/**
* Sorted letters, so "eamstoxil" and "latoximse" share one entry the same
* bag of letters is the same query.
*/
private function normalise(string $anagram): string
{
$letters = str_split(strtolower($anagram));
sort($letters);
return implode('', $letters);
}
/**
* Content-addressed, not mtime: the dictionary ships inside the image, so
* its mtime changes on every rebuild even when the words do not.
*
* Hashing 4.6MB per request would undo the saving, so the hash is itself
* cached under a key made of the cheap stat values that change whenever the
* file is replaced.
*/
private function dictionaryHash(): string
{
$path = self::DICTIONARY;
if (! is_file($path)) {
return 'nodict';
}
$stat = filemtime($path) . ':' . filesize($path);
return Cache::rememberForever(
"anagram:dict:{$stat}",
fn () => hash_file('xxh128', $path),
);
}
}

View file

@ -12,7 +12,10 @@
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// // The container is always behind a reverse proxy that terminates TLS;
// without this Laravel sees plain HTTP and generates http:// redirects
// onto an https:// page.
$middleware->trustProxies(at: '*');
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen( $exceptions->shouldRenderJsonWhen(

View file

@ -3,8 +3,8 @@
# Copy .env.example to .env, set APP_KEY and the database passwords, then: # Copy .env.example to .env, set APP_KEY and the database passwords, then:
# docker compose up -d # docker compose up -d
# #
# The app binds to 127.0.0.1 only. Put a reverse proxy in front of it for # Put a reverse proxy in front of this for anything public-facing, and set
# anything public-facing, and set APP_URL to the address people will use. # APP_URL to the address people will actually use.
services: services:
app: app:
@ -12,7 +12,7 @@ services:
container_name: anagram_app container_name: anagram_app
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:8000:8000" - "${APP_PORT:-8000}:8000"
environment: environment:
APP_NAME: "Anagram Finder" APP_NAME: "Anagram Finder"
APP_ENV: production APP_ENV: production

View file

@ -17,7 +17,7 @@
</head> </head>
<body class="bg-black antialiased min-h-screen flex flex-col items-center p-6 lg:p-8"> <body class="bg-black antialiased min-h-screen flex flex-col items-center p-6 lg:p-8">
<main class="w-full max-w-2xl"> <main class="w-full max-w-2xl">
<h1 class="text-primary font-mono text-5xl font-bold uppercase tracking-widest glow-red-text mb-8"> <h1 class="text-primary font-mono text-3xl sm:text-5xl lg:text-6xl font-bold uppercase tracking-wider lg:tracking-widest glow-red-text mb-6 lg:mb-8">
&gt; Anagram Finder &gt; Anagram Finder
</h1> </h1>
@ -49,16 +49,13 @@
@endisset @endisset
</main> </main>
<footer class="w-full max-w-2xl mt-12 pt-4 border-t border-bordeaux flex flex-wrap items-center justify-between gap-2 font-mono text-xs uppercase tracking-wider text-primary/40"> <footer class="w-full max-w-2xl mt-auto pt-4 -mb-6 lg:-mb-8 pb-4 border-t border-bordeaux text-center font-mono text-[0.65rem] sm:text-xs uppercase tracking-wider text-primary/40">
<span> <span>
{{ config('app.name', 'Anagram Finder') }} {{ config('app.version') }} <a href="https://forge.lvl0.xyz/anagram-finder/web" target="_blank" rel="license noopener noreferrer" class="hover:text-primary transition-colors">{{ config('app.name', 'Anagram Finder') }}</a>
{{ config('app.version') }}
<span class="text-primary/25 px-1">|</span> <span class="text-primary/25 px-1">|</span>
a <a href="https://lvl0.xyz" target="_blank" rel="noopener noreferrer" class="hover:text-primary transition-colors">lvl0</a> project a <a href="https://lvl0.xyz" target="_blank" rel="noopener noreferrer" class="hover:text-primary transition-colors">lvl0</a> project
</span> </span>
<a href="https://forge.lvl0.xyz/anagram-finder/web" target="_blank" rel="license noopener noreferrer" class="hover:text-primary transition-colors">
[SOURCE]
</a>
</footer> </footer>
</body> </body>