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
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,29 @@ REACT_APP_WCA_CLIENT_ID=replace-me
# Server config
MONGO_URL=mongodb://mongo:27017/letscube
REDIS_URL=redis://redis:6379
POSTGRES_ENABLED=true
# DATABASE_URL can replace the individual PG* connection fields.
DATABASE_URL=
# Optional direct connection for Prisma migrations when DATABASE_URL is pooled.
DIRECT_DATABASE_URL=
PGHOST=postgres
PGPORT=5432
PGDATABASE=letscube
PGUSER=letscube
POSTGRES_PASSWORD=replace-with-a-long-random-secret
PGSSL=false
PGSSL_REJECT_UNAUTHORIZED=true
PGSSL_CA=
AUTH_SECRET=replace-with-a-long-random-secret
SESSION_SECRET=
AUTH_CALLBACK_URL=https://letscube.net/auth/callback
WCA_SOURCE=https://www.worldcubeassociation.org
WCA_CLIENT_ID=replace-me
WCA_CLIENT_SECRET=replace-me
CORS_ORIGINS=https://letscube.net,https://www.letscube.net
METRICS_ENABLED=true
METRICS_RETENTION_DAYS=90
METRICS_HASH_SECRET=replace-with-a-different-long-random-secret

# Nginx TLS mounts
LETSENCRYPT_DIR=/etc/letsencrypt
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ jobs:
server:
name: Server lint and unit tests
runs-on: ubuntu-latest
env:
PGHOST: 127.0.0.1
PGPORT: 5432
PGDATABASE: letscube
PGUSER: letscube
PGPASSWORD: letscube
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_DB: letscube
POSTGRES_USER: letscube
POSTGRES_PASSWORD: letscube
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U letscube -d letscube"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Check out repository
uses: actions/checkout@v4
Expand All @@ -24,6 +44,15 @@ jobs:
- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Validate Prisma schema
run: yarn workspace letscube-server postgres:schema:validate

- name: Apply PostgreSQL migrations
run: yarn workspace letscube-server postgres:migrate

- name: Check PostgreSQL schema drift
run: yarn workspace letscube-server postgres:schema:check

- name: Run server lint
run: yarn turbo run lint --filter=letscube-server

Expand Down
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ ENV PORT=8080
ENV SOCKETIO_PORT=9000

WORKDIR /app
RUN groupadd --system letscube \
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system letscube \
&& useradd --system --gid letscube --home-dir /app --shell /usr/sbin/nologin letscube

COPY --chown=letscube:letscube server ./server
Expand Down
66 changes: 62 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,37 @@
# Let's Cube

This is a Progressive Web app written in Node.JS, Express, MongoDB, socket.io, react, and material-ui.
This is a Progressive Web app written in Node.JS, Express, MongoDB,
PostgreSQL, socket.io, React, and Material UI.

This project consists of a client and a server.

## Development:

Make sure Node.JS and Docker are installed. Docker Compose starts the local MongoDB and Redis services used by the app.
Make sure Node.JS and Docker are installed. Docker Compose starts the local
MongoDB, PostgreSQL, and Redis services used by the app.

```
git clone https://github.com/coder13/letscube.git
cd letscube
yarn install
docker compose up -d
docker compose -f docker-compose.yml up -d
```

If MongoDB or Redis are already running locally on their default ports, stop them or skip Docker Compose.
The local PostgreSQL service is exposed on port `55433` to avoid collisions
with other projects. MongoDB remains the source of truth while PostgreSQL is a
non-blocking dual-write target. Prisma owns the PostgreSQL schema and migration
history. Apply committed migrations locally with:

```bash
yarn workspace letscube-server postgres:migrate
```

Create schema changes during development with
`yarn workspace letscube-server postgres:migrate:dev`. Production Compose runs
`prisma migrate deploy` as a separate one-shot service, keeping schema changes
out of the API and Socket.IO startup paths. CI applies every committed migration
to PostgreSQL 17 and checks the resulting database for drift from
`server/prisma/schema.prisma`.

**Server**

Expand All @@ -33,3 +49,45 @@ yarn start:client
```

For more on the internals and contributing, check out the [wiki](https://github.com/coder13/LetsCube/wiki)

## Metrics

The server stores pseudonymous room and authentication events in both the
`metric_events` MongoDB collection and the PostgreSQL `analytics.events` table.
It records room creations, joins, join failures, leaves and visit duration,
accepted result counts, and authentication failures. Peak room users and peak
room solve counts are the maximum `activeUserCount` and `roomSolveCount` values
for each pseudonymous room.

Raw events expire after 90 days by default. Set `METRICS_RETENTION_DAYS` to a
different positive number of days, or set `METRICS_ENABLED=false` to disable
collection. Production should set a dedicated `METRICS_HASH_SECRET`; when it is
not set, the session `AUTH_SECRET` is used. Changing this secret breaks the
ability to correlate pseudonymous users and rooms across the change.

Metrics never include names, email addresses, WCA IDs, room names, passwords,
access codes, OAuth credentials, chat content, scramble text, or solve times.

## PostgreSQL dual writes

New MongoDB writes are mirrored into PostgreSQL without changing application
reads. PostgreSQL receives users and preferences, rooms and participant state,
attempts, durable solve results, and sanitized analytics events. OAuth access
tokens are deliberately not copied. Writes use deterministic UUIDs and upserts,
so retries and future backfills are idempotent. Live room saves mirror only the
attempts and results changed by that save; complete room snapshots are reserved
for explicit backfills. Changing a room event explicitly replaces that room's
PostgreSQL attempts so removed MongoDB attempts do not remain queryable.

Solve penalties use dedicated boolean columns rather than JSON so histories and
statistics remain compact and index-friendly. User solve history is indexed by
creation time and solve ID for stable cursor pagination.

Set `POSTGRES_ENABLED=false` to disable mirroring. Production should set
`PGHOST`, `PGDATABASE`, `PGUSER`, and `POSTGRES_PASSWORD`, or provide a
`DATABASE_URL`. When runtime traffic uses a pooled connection, set
`DIRECT_DATABASE_URL` to the direct connection used by Prisma Migrate. External
TLS connections can set `PGSSL=true` and provide a CA with `PGSSL_CA`;
certificate verification is enabled by default. PostgreSQL failures are logged
but do not fail the corresponding MongoDB-backed application operation during
this migration phase.
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"@material-ui/core": "^4.11.2",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "4.0.0-alpha.46",
"@material-ui/styles": "^4.11.5",
"clsx": "1.1.0",
"connected-react-router": "6.8.0",
"date-fns": "^2.16.1",
Expand Down
2 changes: 1 addition & 1 deletion client/src/lib/Namespace.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// import io from 'socket.io-client';
import * as Protocol from './protocol';
import Protocol from './protocol';

Error.stackTraceLimit = Infinity;

Expand Down
146 changes: 3 additions & 143 deletions client/src/lib/events.js
Original file line number Diff line number Diff line change
@@ -1,145 +1,5 @@
const Events = [
{
id: '333',
scrambler: '333',
name: '3x3',
group: 'WCA',
},
{
id: '222',
scrambler: '222',
name: '2x2',
group: 'WCA',
},
{
id: '444',
scrambler: '444',
name: '4x4',
group: 'WCA',
},
{
id: '555',
scrambler: '555',
name: '5x5',
group: 'WCA',
},
{
id: '666',
scrambler: '666',
name: '6x6',
group: 'WCA',
},
{
id: '777',
scrambler: '777',
name: '7x7',
group: 'WCA',
},
{
id: '333bf',
scrambler: '333',
name: '3x3 Blindfolded',
group: 'WCA',
// }, {
// id: '333fm',
// scrambler: '333',
// name: '3x3x3 Fewest Moves',
},
{
id: '333oh',
scrambler: '333',
name: '3x3 One-Handed',
group: 'WCA',
},
{
id: '333ft',
scrambler: '333',
name: '3x3 With Feet',
group: 'WCA',
},
{
id: 'minx',
scrambler: 'minx',
name: 'Megaminx',
group: 'WCA',
},
{
id: 'pyram',
scrambler: 'pyram',
name: 'Pyraminx',
group: 'WCA',
},
{
id: 'clock',
scrambler: 'clock',
name: 'Clock',
group: 'WCA',
},
{
id: 'clock-optimal',
scrambler: 'clock-optimal',
name: 'Clock (Optimal)',
group: 'Other',
},
{
id: 'skewb',
scrambler: 'skewb',
name: 'Skewb',
group: 'WCA',
},
{
id: 'sq1',
scrambler: 'sq1',
name: 'Square-1',
group: 'WCA',
},
{
id: '444bf',
scrambler: '444',
name: '4x4 Blindfolded',
group: 'WCA',
},
{
id: '555bf',
scrambler: '555',
name: '5x5 Blindfolded',
group: 'WCA',
// }, {
// id: '333mbf',
// name: '3x3x3 Multi-Blind',
},
{
id: 'fto',
scrambler: 'fto',
name: 'FTO',
group: 'Other',
},
{
id: 'pll',
scrambler: 'pll',
name: 'PLL',
group: 'Other',
},
{
id: 'zbll',
scrambler: 'zbll',
name: 'ZBLL',
group: 'Other',
},
{
id: 'lse',
scrambler: 'lse',
name: 'Last Six Edges',
group: 'Other',
},
{
id: 'ru',
scrambler: 'ru',
name: 'RU 2gen',
group: 'Other',
},
];
import Events from './events.json';

module.exports.Events = Events;
export { Events };

module.exports.getNameFromId = (eventId) => Events.find((e) => e.id === eventId).name;
export const getNameFromId = (eventId) => Events.find((event) => event.id === eventId).name;
Loading
Loading