ditch scala

This commit is contained in:
Will Freeman
2026-01-01 19:04:08 -06:00
parent 8d27a8732b
commit 4b953023b3
21 changed files with 2277 additions and 375 deletions

34
.github/workflows/deploy.yaml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Deploy DeFlock Backend
on:
push:
branches: [master]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Build TypeScript
run: npm run build
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: app
key: ${{ secrets.VPS_SSH_KEY }}
script: |
mkdir -p /opt/app/dist
rsync -avz --delete ./dist/ /opt/app/dist/
pm2 reload my-app

4
backend/.env.example Normal file
View File

@@ -0,0 +1,4 @@
# Environment variables
GITHUB_TOKEN=your_github_token_here
PORT=8080
NODE_ENV=development

4
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.env
*.log

18
backend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy built application
COPY dist ./dist
# Expose port
EXPOSE 8080
# Start server
CMD ["node", "dist/server.js"]

94
backend/README.md Normal file
View File

@@ -0,0 +1,94 @@
# DeFlock Backend Service
A Node.js TypeScript service providing API endpoints for the DeFlock application.
## Features
- **GitHub Sponsors**: Fetch GitHub sponsors data
- **Geocoding**: Geocode addresses using Nominatim with LRU caching to avoid rate limits
## Prerequisites
- Node.js 18+
- npm or yarn
## Setup
1. Install dependencies:
```bash
npm install
```
2. Create a `.env` file based on `.env.example`:
```bash
cp .env.example .env
```
3. Add your GitHub token to `.env`:
```
GITHUB_TOKEN=your_github_token_here
PORT=8080
```
## Development
Start the development server with hot reload:
```bash
npm run dev
```
## Production
Build and start the production server:
```bash
npm run build
npm start
```
## API Endpoints
### Health Check
- `GET /api/healthcheck` - Returns service health status
- `HEAD /api/healthcheck` - Returns 200 OK
### GitHub Sponsors
- `GET /api/sponsors/github` - Fetches GitHub sponsors for the configured user
### Geocoding
- `GET /api/geocode?query=<address>` - Geocodes an address using Nominatim
- Uses LRU cache (max 300 entries) to minimize API calls
- Respects Nominatim rate limits
## Configuration
### Environment Variables
- `GITHUB_TOKEN` - GitHub personal access token with sponsorship read permissions
- `PORT` - Server port (default: 8080)
- `NODE_ENV` - Environment mode (development/production)
### CORS
Allowed origins are configured in [server.ts](src/server.ts):
- http://localhost:8080
- http://localhost:5173
- https://deflock.me
- https://www.deflock.me
### Caching
The geocoding service uses an LRU cache with:
- Max 300 entries
- Automatic eviction of least recently used entries
- No TTL (cache persists until server restart)
## Project Structure
```
backend/
├── src/
│ ├── server.ts # Main server file
│ └── services/
│ ├── github.ts # GitHub API client
│ └── nominatim.ts # Nominatim geocoding client with cache
├── package.json
├── tsconfig.json
└── .env.example
```

1866
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
backend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "deflock-backend",
"version": "1.0.0",
"description": "Backend API service for DeFlock",
"main": "dist/server.js",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"lint": "eslint src --ext .ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"axios": "^1.6.2",
"lru-cache": "^10.1.0",
"dotenv": "^16.3.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/node": "^20.10.5",
"typescript": "^5.3.3",
"ts-node-dev": "^2.0.0"
}
}

80
backend/src/server.ts Normal file
View File

@@ -0,0 +1,80 @@
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { GithubClient } from './services/github';
import { NominatimClient } from './services/nominatim';
dotenv.config();
const app = express();
const port = process.env.PORT || 8080;
const githubClient = new GithubClient();
const nominatimClient = new NominatimClient();
const allowedOrigins = [
'http://localhost:8080',
'http://localhost:5173',
'https://deflock.me',
'https://www.deflock.me',
];
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps or curl)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true
}));
app.use(express.json());
app.head('/healthcheck', (req: Request, res: Response) => {
res.status(200).end();
});
app.get('/sponsors/github', async (req: Request, res: Response) => {
try {
const sponsors = await githubClient.getSponsors('frillweeman');
res.json(sponsors);
} catch (error) {
console.error('Error fetching GitHub sponsors:', error);
res.status(500).json({ error: 'Failed to fetch GitHub sponsors' });
}
});
app.get('/geocode', async (req: Request, res: Response) => {
const query = req.query.query as string;
if (!query) {
return res.status(400).json({ error: 'Query parameter is required' });
}
try {
const results = await nominatimClient.geocodePhrase(query);
res.json(results);
} catch (error) {
console.error('Error geocoding:', error);
res.status(500).json({ error: 'Failed to geocode phrase' });
}
});
app.use((req: Request, res: Response) => {
res.status(404).json({ error: 'The requested resource could not be found.' });
});
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
console.log('Press Ctrl+C to stop...');
});

View File

@@ -0,0 +1,75 @@
import axios from 'axios';
interface GithubSponsor {
login: string;
name: string | null;
avatarUrl: string;
url: string;
}
interface GithubSponsorsResponse {
data: {
user: {
sponsorshipsAsMaintainer: {
nodes: Array<{
sponsor: GithubSponsor;
}>;
};
};
};
}
export class GithubClient {
private readonly graphQLEndpoint = 'https://api.github.com/graphql';
private readonly githubApiToken: string;
constructor() {
const token = process.env.GITHUB_TOKEN;
if (!token) {
throw new Error('GITHUB_TOKEN environment variable is required');
}
this.githubApiToken = token;
}
async getSponsors(username: string): Promise<GithubSponsor[]> {
const query = `
query {
user(login: "${username}") {
sponsorshipsAsMaintainer(first: 100) {
nodes {
sponsor {
login
name
avatarUrl
url
}
}
}
}
}
`;
try {
const response = await axios.post<GithubSponsorsResponse>(
this.graphQLEndpoint,
{
query,
variables: {}
},
{
headers: {
'Authorization': `Bearer ${this.githubApiToken}`,
'User-Agent': 'DeFlock Backend',
'Content-Type': 'application/json'
}
}
);
const nodes = response.data.data.user.sponsorshipsAsMaintainer.nodes;
return nodes.map(node => node.sponsor);
} catch (error) {
console.error('Failed to fetch GitHub sponsors:', error);
throw new Error('Failed to fetch GitHub sponsors');
}
}
}

View File

@@ -0,0 +1,56 @@
import axios from 'axios';
import { LRUCache } from 'lru-cache';
interface NominatimResult {
lat: string;
lon: string;
display_name: string;
geojson?: any;
[key: string]: any;
}
export class NominatimClient {
private readonly baseUrl = 'https://nominatim.openstreetmap.org/search';
private cache: LRUCache<string, NominatimResult[]>;
constructor() {
// LRU cache with max 300 entries and no TTL
// This keeps memory usage reasonable while caching frequent queries
this.cache = new LRUCache<string, NominatimResult[]>({
max: 300,
// Optional: add TTL if you want cache entries to expire
// ttl: 1000 * 60 * 60 * 24, // 24 hours
});
}
async geocodePhrase(query: string): Promise<NominatimResult[]> {
// Check cache first
const cached = this.cache.get(query);
if (cached) {
console.log(`Cache hit for: ${query}`);
return cached;
}
console.log(`Cache miss for: ${query}`);
try {
const response = await axios.get<NominatimResult[]>(this.baseUrl, {
params: {
q: query,
polygon_geojson: 1,
format: 'json'
},
headers: {
'User-Agent': 'DeFlock/1.0'
}
});
// Store in cache before returning
this.cache.set(query, response.data);
return response.data;
} catch (error) {
console.error('Failed to geocode phrase:', error);
throw new Error('Failed to geocode phrase');
}
}
}

17
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

70
shotgun/.gitignore vendored
View File

@@ -1,70 +0,0 @@
#
# Are you tempted to edit this file?
#
# First consider if the changes make sense for all,
# or if they are specific to your workflow/system.
# If it is the latter, you can augment this list with
# entries in .git/info/excludes
#
# see also test/files/.gitignore
#
#
# JARs aren't checked in, they are fetched by sbt
#
/lib/*.jar
/test/files/codelib/*.jar
/test/files/lib/*.jar
/test/files/speclib/instrumented.jar
/tools/*.jar
# Developer specific properties
/build.properties
/buildcharacter.properties
# might get generated when testing Jenkins scripts locally
/jenkins.properties
# target directory for build
/build/
# other
/out/
/bin/
/sandbox/
# intellij
/src/intellij*/*.iml
/src/intellij*/*.ipr
/src/intellij*/*.iws
**/.cache
/.idea
/.settings
# vscode
/.vscode
# Standard symbolic link to build/quick/bin
/qbin
# sbt's target directories
/target/
/project/**/target/
/test/macro-annot/target/
/test/files/target/
/test/target/
/build-sbt/
local.sbt
jitwatch.out
# Used by the restarr/restarrFull commands as target directories
/build-restarr/
/target-restarr/
# metals
.metals
.bloop
project/**/metals.sbt
.bsp
.history

View File

@@ -1,32 +0,0 @@
import Dependencies._
ThisBuild / scalaVersion := "2.12.8"
ThisBuild / version := "0.1.0-SNAPSHOT"
ThisBuild / organization := "me.deflock"
ThisBuild / organizationName := "DeFlock"
lazy val root = (project in file("."))
.settings(
name := "shotgun",
libraryDependencies += scalaTest % Test,
)
val PekkoVersion = "1.0.3"
val PekkoHttpVersion = "1.0.1"
libraryDependencies ++= Seq(
"ch.qos.logback" % "logback-classic" % "1.5.6",
"org.slf4j" % "slf4j-api" % "2.0.12",
"org.apache.pekko" %% "pekko-actor-typed" % PekkoVersion,
"org.apache.pekko" %% "pekko-stream" % PekkoVersion,
"org.apache.pekko" %% "pekko-http" % PekkoHttpVersion,
"org.apache.pekko" %% "pekko-http-spray-json" % PekkoHttpVersion,
"org.apache.pekko" %% "pekko-http-cors" % PekkoHttpVersion,
"org.apache.pekko" %% "pekko-slf4j" % PekkoVersion,
)
assembly / assemblyMergeStrategy := {
case PathList("module-info.class") => MergeStrategy.first
case x =>
val oldStrategy = (assembly / assemblyMergeStrategy).value
oldStrategy(x)
}

View File

@@ -1,5 +0,0 @@
import sbt._
object Dependencies {
lazy val scalaTest = "org.scalatest" %% "scalatest" % "3.0.5"
}

View File

@@ -1 +0,0 @@
sbt.version=1.9.1

View File

@@ -1 +0,0 @@
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.1.0")

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
</configuration>

View File

@@ -1,123 +0,0 @@
package me.deflock.shotgun
import org.apache.pekko
import org.apache.pekko.actor.ActorSystem
import org.apache.pekko.event.Logging
import org.apache.pekko.http.cors.scaladsl.CorsDirectives.cors
import org.apache.pekko.http.cors.scaladsl.model.HttpOriginMatcher
import org.apache.pekko.http.cors.scaladsl.settings.CorsSettings
import org.apache.pekko.http.scaladsl.model.headers.{HttpOrigin, `Access-Control-Allow-Origin`}
import pekko.http.scaladsl.Http
import pekko.http.scaladsl.model._
import pekko.http.scaladsl.server.Directives.{path, _}
import org.apache.pekko.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._
import org.apache.pekko.http.scaladsl.server.RejectionHandler
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import scala.concurrent.ExecutionContextExecutor
import scala.io.StdIn
import org.slf4j.LoggerFactory
object ShotgunServer {
val logger = LoggerFactory.getLogger(getClass)
def main(args: Array[String]): Unit = {
implicit val system: ActorSystem = ActorSystem("my-system")
implicit val executionContext: ExecutionContextExecutor = system.dispatcher
val logging = Logging(system, getClass)
val nominatim = new services.NominatimClient()
val githubClient = new services.GithubClient()
// CORS
val allowedOrigins = List(
"http://localhost:8080",
"http://localhost:5173",
"https://deflock.me",
"https://www.deflock.me",
).map(HttpOrigin(_)) // TODO: make this a config setting
val corsSettings = CorsSettings.default
.withAllowedOrigins(HttpOriginMatcher(allowedOrigins: _*))
.withExposedHeaders(List(`Access-Control-Allow-Origin`.name))
val rejectionHandler = RejectionHandler.newBuilder()
.handleNotFound {
complete((StatusCodes.NotFound, "The requested resource could not be found."))
}
.handle {
case corsRejection: org.apache.pekko.http.cors.scaladsl.CorsRejection =>
complete((StatusCodes.Forbidden, "CORS rejection: Invalid origin"))
}
.result()
val apiRoutes = pathPrefix("api") {
concat (
path("geocode") {
get {
parameters("query".as[String]) { query =>
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString)
onSuccess(nominatim.geocodePhrase(encodedQuery)) { json =>
complete(json)
}
}
}
},
path("sponsors" / "github") {
get {
onSuccess(githubClient.getSponsors("frillweeman")) { json =>
complete(json)
}
}
},
path("oauth2" / "callback") {
get {
parameters(Symbol("code").?) { (code) =>
complete(HttpEntity(ContentTypes.`text/html(UTF-8)`, "<h1>Say hello to Pekko HTTP</h1><p><b>Code: " + code.getOrElse("None") + "</b></p>"))
}
}
},
path("healthcheck") {
get {
complete(HttpResponse(StatusCodes.OK, entity = "Service is healthy"))
}
head {
complete(StatusCodes.OK)
}
}
)
}
val spaRoutes = pathEndOrSingleSlash {
getFromFile("../webapp/dist/index.html")
} ~ getFromDirectory("../webapp/dist") ~
path(Remaining) { _ =>
getFromFile("../webapp/dist/index.html")
}
val routes = handleRejections(rejectionHandler) {
cors(corsSettings) {
concat(apiRoutes, spaRoutes)
}
}
val bindingFuture = Http().newServerAt("0.0.0.0", 8080).bind(routes)
// Handle the binding future properly
bindingFuture.foreach { binding =>
println(s"Server online at http://localhost:${binding.localAddress.getPort}/")
println("Press RETURN to stop...")
}
StdIn.readLine()
bindingFuture
.flatMap(_.unbind())
.onComplete { _ =>
println("Server shutting down...")
system.terminate()
}
}
}

View File

@@ -1,69 +0,0 @@
package services
import org.apache.pekko.actor.ActorSystem
import org.apache.pekko.http.javadsl.model.headers.{Authorization, HttpCredentials, UserAgent}
import org.apache.pekko.http.scaladsl.Http
import org.apache.pekko.http.scaladsl.model.{HttpMethods, HttpRequest, StatusCodes}
import org.apache.pekko.http.scaladsl.unmarshalling.Unmarshal
import spray.json.JsValue
import spray.json._
import org.apache.pekko.http.scaladsl.model.ContentTypes
import org.apache.pekko.http.scaladsl.model.HttpEntity
import org.slf4j.LoggerFactory
import scala.concurrent.{ExecutionContextExecutor, Future}
class GithubClient(implicit val system: ActorSystem, implicit val executionContext: ExecutionContextExecutor) {
val logger = LoggerFactory.getLogger(getClass)
val graphQLEndpoint = "https://api.github.com/graphql"
private val githubApiToken = sys.env("GITHUB_TOKEN")
def getSponsors(username: String): Future[JsArray] = {
val query = s"""
|query {
| user(login: "$username") {
| sponsorshipsAsMaintainer(first: 100) {
| nodes {
| sponsor {
| login
| name
| avatarUrl
| url
| }
| }
| }
| }
|}
|""".stripMargin.replace("\n", " ").replace("\"", "\\\"")
val jsonRequest = s"""{"query": "$query", "variables": ""}"""
val jsonEntity = HttpEntity(ContentTypes.`application/json`, jsonRequest)
val request = HttpRequest(
headers = List(
UserAgent.create("Shotgun"),
Authorization.create(HttpCredentials.create("Bearer", githubApiToken))
),
method = HttpMethods.POST,
uri = graphQLEndpoint,
entity = jsonEntity
)
Http().singleRequest(request).flatMap { response =>
response.status match {
case StatusCodes.OK =>
Unmarshal(response.entity).to[String].map { jsonString =>
jsonString.parseJson.asJsObject
.fields("data").asJsObject
.fields("user").asJsObject
.fields("sponsorshipsAsMaintainer")
.asJsObject.fields("nodes")
.asInstanceOf[JsArray]
}
case _ =>
response.discardEntityBytes()
Future.failed(new Exception(s"Failed to get sponsors: ${response.status}"))
}
}
}
}

View File

@@ -1,53 +0,0 @@
package services
import org.apache.pekko
import org.apache.pekko.actor.ActorSystem
import pekko.http.scaladsl.Http
import pekko.http.scaladsl.model._
import pekko.http.scaladsl.unmarshalling.Unmarshal
import spray.json._
import scala.collection.mutable
import scala.concurrent.{ExecutionContextExecutor, Future}
class NominatimClient(implicit val system: ActorSystem, implicit val executionContext: ExecutionContextExecutor) {
val baseUrl = "https://nominatim.openstreetmap.org/search"
private val cache: mutable.LinkedHashMap[String, JsValue] = new mutable.LinkedHashMap[String, JsValue]()
private val maxCacheSize = 300
private def cleanUpCache(): Unit = {
if (cache.size > maxCacheSize) {
val oldest = cache.head
cache.remove(oldest._1)
}
}
def geocodePhrase(query: String): Future[JsValue] = {
cleanUpCache()
cache.get(query) match {
case Some(cachedResult) =>
println(s"Cache hit for $query")
Future.successful(cachedResult)
case _ =>
println(s"Cache miss for $query")
val request = HttpRequest(
uri = s"$baseUrl?q=$query&polygon_geojson=1&format=json",
headers = List(headers.`User-Agent`("DeFlock/1.0"))
)
Http().singleRequest(request).flatMap { response =>
response.status match {
case StatusCodes.OK =>
Unmarshal(response.entity).to[String].map { jsonString =>
val json = jsonString.parseJson
cache.put(query, json)
json
}
case _ =>
response.discardEntityBytes()
Future.failed(new Exception(s"Failed to geocode phrase: ${response.status}"))
}
}
}
}
}

View File

@@ -1,9 +0,0 @@
package example
import org.scalatest._
class HelloSpec extends FlatSpec with Matchers {
"The Hello object" should "say hello" in {
Hello.greeting shouldEqual "hello"
}
}