mirror of
https://github.com/FoggedLens/deflock.git
synced 2026-02-12 15:02:45 +00:00
ditch scala
This commit is contained in:
34
.github/workflows/deploy.yaml
vendored
Normal file
34
.github/workflows/deploy.yaml
vendored
Normal 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
4
backend/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# Environment variables
|
||||
GITHUB_TOKEN=your_github_token_here
|
||||
PORT=8080
|
||||
NODE_ENV=development
|
||||
4
backend/.gitignore
vendored
Normal file
4
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
18
backend/Dockerfile
Normal file
18
backend/Dockerfile
Normal 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
94
backend/README.md
Normal 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
1866
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
backend/package.json
Normal file
29
backend/package.json
Normal 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
80
backend/src/server.ts
Normal 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...');
|
||||
});
|
||||
75
backend/src/services/github.ts
Normal file
75
backend/src/services/github.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
56
backend/src/services/nominatim.ts
Normal file
56
backend/src/services/nominatim.ts
Normal 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
17
backend/tsconfig.json
Normal 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
70
shotgun/.gitignore
vendored
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import sbt._
|
||||
|
||||
object Dependencies {
|
||||
lazy val scalaTest = "org.scalatest" %% "scalatest" % "3.0.5"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
sbt.version=1.9.1
|
||||
@@ -1 +0,0 @@
|
||||
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.1.0")
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user