Skip to content

Commit

Permalink
Merge pull request #15 from mpiorowski/feat/upgrade
Browse files Browse the repository at this point in the history
Big Upgrade
  • Loading branch information
mpiorowski authored Mar 10, 2024
2 parents c3f9672 + cb328cd commit b492952
Show file tree
Hide file tree
Showing 89 changed files with 2,600 additions and 2,416 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/lint-client.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ jobs:
working-directory: ./client
steps:
- name: Check out repository code
uses: actions/checkout@v3
uses: actions/checkout@v4
- run: npm i
- run: npm run build
- run: npm run lint
- run: npm run check
- run: npm run lint
- run: npm run build
10 changes: 6 additions & 4 deletions .github/workflows/lint-server.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v4
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.21.1"
go-version: "1.22"
cache: false
- uses: actions/checkout@v3
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v4
with:
version: latest
working-directory: ./server
args: --skip-dirs=proto --timeout=3m
- name: run tests
run: cd server && go test -v ./...
61 changes: 44 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,38 @@ https://sgsg.bearbyte.org/

## What is SGSG?

It is an open-source full-stack application with two main principles in mind: **PERFORMANCE** and **SIMPLICITY**.
It is an open-source full-stack application with two main principles in mind: **PERFORMANCE** and **SIMPLICITY**.
The idea is that you can take this template and use it to build almost anything you need, and it will scale very well.

Also, this is not the next **dev** template. It has everything you need to push it to production: Nginx configuration, Docker deployments, GitHub Actions, Grafana logging, etc.

## Alternative

If you need something a little more complicated (Rust, microservices, PostgreQSL, cloud deployment), feel free to check out the second project I am running:
**[Rusve](https://github.com/mpiorowski/rusve)**

## Architecture

As the name suggests, the app contains four main components:

- **[SvelteKit](https://kit.svelte.dev/)** - Svelte currently is what I believe the best frontend framework. If you've never worked with it, don't worry; it's super easy to pick up.
As an example, developers from my team who were familiar with NextJS were able to grasp it in just one week and start coding web apps. Trust me, once you try it, it's hard to go back to anything else.
As an example, developers from my team who were familiar with NextJS were able to grasp it in just one week and start coding web apps. Trust me, once you try it, it's hard to go back to anything else.
- **[Go](https://go.dev/)** - The easiest backend on the market. Don't confuse simplicity with inefficiency; it's almost impossible to build a bad server using it.
- **[SQLite](https://www.sqlite.org/index.html)** - The most used database in the world. You might be skeptical about using SQLite for production, but trust me, unless you're building the next Netflix, this is all you need.
It will be faster than your PostgreSQL or MySQL because the database sits next to the backend, eliminating one network connection. Also changing it to **PostgreSQL/MySQL** should take only a minute :).
It will be faster than your PostgreSQL or MySQL because the database sits next to the backend, eliminating one network connection. Also changing it to **PostgreSQL/MySQL** should take only a minute :).
- **[gRPC](https://grpc.io/)** - Now we are delving into something a little bit harder to grasp, but I believe it's totally worth it. Two of the most important things it gives us:
- **[Typesafety](https://protobuf.dev/)** - Thanks to protobuf, there is amazing type safety across the whole project, regardless of the language (not only for TypeScript, hi tRPC). Trust me; this is phenomenal.
If you add one "field" to your User object, both JavaScript and Go will lint, pointing out exactly where you need to take care of it. Adding a new language like Java or Rust? Type safety for them as well.
- **[Streaming](https://grpc.io/docs/what-is-grpc/core-concepts/#server-streaming-rpc)** - gRPC allows streaming data, which, for larger datasets, offers incredible performance. Add to this Go routines, and you can create the most amazing backend services.
Load measurements concurrently start doing calculations on them and send them via streams as soon as any of them finish. No waiting, no blocking.
- **[Typesafety](https://protobuf.dev/)** - Thanks to protobuf, there is amazing type safety across the whole project, regardless of the language (not only for TypeScript, hi tRPC). Trust me; this is phenomenal.
If you add one "field" to your User object, both JavaScript and Go will lint, pointing out exactly where you need to take care of it. Adding a new language like Java or Rust? Type safety for them as well.
- **[Streaming](https://grpc.io/docs/what-is-grpc/core-concepts/#server-streaming-rpc)** - gRPC allows streaming data, which, for larger datasets, offers incredible performance. Add to this Go routines, and you can create the most amazing backend services.
Load measurements concurrently start doing calculations on them and send them via streams as soon as any of them finish. No waiting, no blocking.

If You have any questions, feel free to ask them in Discussions or Issues. I hope this will be helpful :).

## Additional features

- **Stripe Subscription** - Fully working subscription flow.
- **No TypeScript Build, Fully Typed with JSDocs** - Despite the absence of a TypeScript build, the code remains fully typed using JSDocs. While this approach may be somewhat controversial due to requiring more lines of code, the more I work with pure JSDocs, the more I appreciate its versatility.
It supports features like Enums, as const, and even Zod's z.infer<typeof User>, eliminating the need for the entire TypeScript build step.
It supports features like Enums, as const, and even Zod's z.infer<typeof User>, eliminating the need for the entire TypeScript build step.
- **Fully Tested** - Backend tested using go native modules, frontend using vitest and playwright.
- **Very Secure OAuth Implementation** - Utilizes the Phantom Token Approach with additional client-to-server authorization using an RSA key, ensuring robust security.
- **Minimal External Libraries** - Emphasizes a minimalistic approach to external libraries. From my experience, relying less on external dependencies contributes to code maintainability. This approach makes it easier to make changes even after years. It's simply the way to go.
Expand All @@ -46,12 +51,14 @@ Thx ChatGPT for these bullet points :).
## Test

Backend:

```
cd server
ENV=test go test ./...
go test -v ./...
```

Frontend:

```
cd client
npm run test:unit
Expand All @@ -61,18 +68,30 @@ npm run test:integration
## Proto

Whenever You change proto definitions, always remember to generate new types:

```
sh proto.sh
```

## Backup

To backup the database, run the following command:

```
sqlite3 server/system/db.sqlite3 ".backup 'server/system/db.sqlite3.bak'"
```

## Deployment

The only prerequisites are `Docker` and `Docker Compose`.
The only prerequisites are `Docker` and `Docker Compose`.

Afterward, the only task remaining is to configure environment variables according to the deployment. No need for .env files or tedious copy/pasting — just straightforward environment variables, either configured on the system or written inline.

### Development

```
STRIPE_API_KEY=STRIPE_API_KEY \
STRIPE_PRICE_ID=STRIPE_PRICE_ID \
GOOGLE_CLIENT_ID=GOOGLE_CLIENT_ID \
GOOGLE_CLIENT_SECRET=GOOGLE_CLIENT_SECRET \
GITHUB_CLIENT_ID=GITHUB_CLIENT_ID \
Expand All @@ -86,13 +105,15 @@ docker compose up --build
Let's embark on the full journey:

1. Generate new RSA keys and push them to your repository:

```
openssl genpkey -algorithm RSA -out private.key -pkeyopt rsa_keygen_bits:2048
openssl rsa -pubout -in private.key -out public.key
mv private.key ./client/src/lib/server/private.key
mv public.key ./server/public.key
```

2. Purchase two servers (Hetzner CPX11 costs 5 euros/month each), one for the client and one for the server, with SSH key authorization.
3. Add three repository secrets to GitHub:
- SSH_KEY: private version of the SSH key used for logging into servers
Expand All @@ -103,28 +124,34 @@ mv public.key ./server/public.key
From here, you need to follow these instructions on both servers:

5. Log in and create a new SSH key:

```
ssh-keygen -t ed25519 -C [email protected]
```

6. Add the `.pub` version as the deployment key in your repository settings.
7. Download the repository into server.
8. Finish setting up environment variables:
- Change domains in `docker-compose.client.yml` / `docker-compose.server.yml`
- Add environment variables to your system, e.g., using Fish: `set -Ux GOOGLE_CLIENT_ID 123456789`.
- You can find all needed environment variables in `docker-compose.client.yml` / `docker-compose.server.yml`. Don't miss out on the `LOKI_URL` one — you can find it on the Grafana Cloud Portal -> Loki Details.
- Change domains in `docker-compose.client.yml` / `docker-compose.server.yml`
- Add environment variables to your system, e.g., using Fish: `set -Ux GOOGLE_CLIENT_ID 123456789`.
- You can find all needed environment variables in `docker-compose.client.yml` / `docker-compose.server.yml`. Don't miss out on the `LOKI_URL` one — you can find it on the Grafana Cloud Portal -> Loki Details.
9. Generate certificates for your domain:

```
sudo apt-get install certbot python3-certbot-nginx
sudo certbot certonly --nginx -d example.com
sudo apt-get install certbot
sudo certbot certonly --standalone -d example.com
sudo certbot renew --dry-run
sudo systemctl disable nginx
```
10. Install the Grafana Loki plugin for Docker:

10. Install the Grafana Loki plugin for Docker (check the latest version on the Grafana website):

```
docker plugin install grafana/loki-docker-driver:2.8.2 --alias loki --grant-all-permissions
```

11. Restart server.
12. Log in and run the app for the first time to ensure everything is correct:

```
cd you-repo
git checkout release
Expand Down
56 changes: 38 additions & 18 deletions client/src/hooks.server.js
Original file line number Diff line number Diff line change
@@ -1,62 +1,82 @@
import { COOKIE_DOMAIN } from "$env/static/private";
import { grpcSafe } from "$lib/safe";
import { server } from "$lib/server/grpc";
import { UserRole } from "$lib/proto/proto/UserRole";
import { grpcSafe, server } from "$lib/server/grpc";
import { logger, perf } from "$lib/server/logger";
import { createMetadata } from "$lib/server/metadata";
import { redirect } from "@sveltejs/kit";
import { building } from "$app/environment";

/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
const end = perf("Auth");
if (building) {
return await resolve(event);
}

const end = perf("auth");
event.locals.user = {
id: "",
created: "",
updated: "",
deleted: "",
email: "",
avatar: "",
role: 0,
role: UserRole.ROLE_UNSET,
sub: "",
subscriptionId: "",
subscriptionEnd: "",
_deleted: "deleted",
_subscriptionEnd: "subscriptionEnd",
subscription_id: "",
subscription_end: "",
subscription_check: "",
subscription_active: false,
};

if (event.url.pathname === "/auth") {
event.cookies.set("token", "", {
domain: COOKIE_DOMAIN,
path: "/",
maxAge: 0,
});
return await resolve(event);
}

const token = event.cookies.get("token");
/**
* Check if the user is coming from the oauth flow
* If so, set a temporary cookie with the token
* On the next request, the new token will be used
*/
let token = event.url.pathname.includes("/token/")
? event.url.pathname.split("/token/")[1]
: "";
if (token) {
event.cookies.set("token", token, {
path: "/",
maxAge: 10,
});
throw redirect(302, "/");
}

token = event.cookies.get("token") ?? "";
if (!token) {
logger.info("No token");
throw redirect(302, "/auth");
throw redirect(302, "/auth?error=1");
}

const metadata = createMetadata(token);
/** @type {import("$lib/safe").Safe<import("$lib/proto/proto/AuthResponse").AuthResponse__Output>} */
/** @type {import("$lib/server/safe").Safe<import("$lib/proto/proto/AuthResponse").AuthResponse__Output>} */
const auth = await new Promise((res) => {
server.Auth({}, metadata, grpcSafe(res));
});
if (auth.error || !auth.data.tokenId || !auth.data.user) {
if (!auth.success || !auth.data.token || !auth.data.user) {
logger.error("Error during auth");
throw redirect(302, "/auth");
throw redirect(302, "/auth?error=1");
}

event.locals.user = auth.data.user;
event.locals.token = auth.data.tokenId;
event.locals.token = auth.data.token;
// logger.debug(event.locals.user, "user");

end();
const response = await resolve(event);
// max age is 30 days
// max age is 7 days
response.headers.append(
"set-cookie",
`token=${auth.data.tokenId}; HttpOnly; SameSite=Lax; Secure; Max-Age=2592000; Path=/; Domain=${COOKIE_DOMAIN}`,
`token=${auth.data.token}; HttpOnly; SameSite=Lax; Secure; Max-Age=604800; Path=/`,
);
return response;
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
syntax = "proto3";

package proto;

option go_package = "sgsg/proto";
package proto;

enum UserRole {
ROLE_UNSPECIFIED = 0;
ROLE_UNSET = 0;
ROLE_USER = 1;
ROLE_ADMIN = 2;
}
Expand All @@ -14,12 +12,15 @@ message User {
string id = 1;
string created = 2;
string updated = 3;
optional string deleted = 4;
string deleted = 4;

string email = 5;
UserRole role = 6;
string sub = 7;
string sub = 6;
UserRole role = 7;
string avatar = 8;

string subscription_id = 9;
optional string subscription_end = 10;
string subscription_end = 10;
string subscription_check = 11;
bool subscription_active = 12;
}
File renamed without changes.
15 changes: 10 additions & 5 deletions client/src/lib/proto/main.proto
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
syntax = "proto3";

option go_package = "sgsg/proto";
package proto;

option go_package = "sgsg/proto";

import "user.proto";
import "auth.proto";
import "profile.proto";
import "note.proto";

Expand All @@ -15,16 +14,22 @@ message Id {
}

message AuthResponse {
string tokenId = 1;
string token = 1;
User user = 2;
}

message StripeUrlResponse {
string url = 1;
}

service Service {
rpc Auth(Empty) returns (AuthResponse) {}

rpc CreateStripeCheckout(Empty) returns (StripeUrlResponse) {}
rpc CreateStripePortal(Empty) returns (StripeUrlResponse) {}

rpc GetProfileByUserId(Empty) returns (Profile) {}
rpc CreateProfile(Profile) returns (Profile) {}
rpc DeleteProfileById(Id) returns (Empty) {}

rpc GetNotesByUserId(Empty) returns (stream Note) {}
rpc GetNoteById(Id) returns (Note) {}
Expand Down
1 change: 1 addition & 0 deletions client/src/lib/proto/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface ProtoGrpcType {
Note: MessageTypeDefinition
Profile: MessageTypeDefinition
Service: SubtypeConstructor<typeof grpc.Client, _proto_ServiceClient> & { service: _proto_ServiceDefinition }
StripeUrlResponse: MessageTypeDefinition
User: MessageTypeDefinition
UserRole: EnumTypeDefinition
}
Expand Down
2 changes: 1 addition & 1 deletion client/src/lib/proto/note.proto
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ message Note {
string id = 1;
string created = 2;
string updated = 3;
optional string deleted = 4;
string deleted = 4;

string user_id = 5;
string title = 6;
Expand Down
2 changes: 1 addition & 1 deletion client/src/lib/proto/profile.proto
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ message Profile {
string id = 1;
string created = 2;
string updated = 3;
optional string deleted = 4;
string deleted = 4;

string user_id = 5;
string username = 6;
Expand Down
Loading

0 comments on commit b492952

Please sign in to comment.