├── backend ├── README.md ├── Makefile ├── schema.sql ├── Dockerfile.dev ├── main.go ├── bin │ └── entrypoint.sh ├── internal │ ├── api │ │ ├── server │ │ │ ├── server.go │ │ │ └── server_test.go │ │ ├── router │ │ │ ├── router_test.go │ │ │ └── router.go │ │ └── handlers │ │ │ ├── articles.go │ │ │ └── articles_test.go │ ├── models │ │ ├── models_test.go │ │ └── article.go │ └── store │ │ ├── mockstore.go │ │ └── mockstore_test.go ├── Dockerfile ├── go.mod ├── config │ ├── config.go │ └── config_test.go ├── docs │ ├── swagger.yaml │ ├── swagger.json │ └── docs.go └── go.sum ├── frontend ├── README.md ├── public │ ├── favicon.ico │ ├── images │ │ └── photo.jpg │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ └── site.webmanifest ├── babel.config.js ├── jest.config.cjs ├── next-env.d.ts ├── Dockerfile ├── lib │ ├── theme.ts │ ├── siteMetaData.ts │ └── stringUtils.ts ├── Dockerfile.dev ├── .gitignore ├── next.config.js ├── Makefile ├── components │ ├── ArticleList.tsx │ ├── Description.tsx │ ├── Footer.tsx │ ├── ModeButton.tsx │ ├── __tests__ │ │ ├── Description.test.tsx │ │ └── NavBar.test.tsx │ ├── Layout.tsx │ ├── NavBar.tsx │ └── ArticleListItem.tsx ├── tsconfig.json ├── pages │ ├── index.tsx │ ├── _app.tsx │ ├── about.tsx │ └── _document.tsx ├── package.json ├── types │ └── index.ts └── hooks │ └── useArticles.ts ├── .dockerignore ├── rss ├── src │ ├── routes │ │ ├── mod.rs │ │ └── rss.rs │ ├── config │ │ ├── mod.rs │ │ └── config.rs │ ├── errors │ │ ├── mod.rs │ │ └── errors.rs │ ├── models │ │ ├── mod.rs │ │ └── article.rs │ ├── services │ │ ├── mod.rs │ │ └── articles.rs │ └── main.rs ├── Makefile ├── .gitignore ├── Cargo.toml ├── Dockerfile.dev └── Dockerfile ├── hackernews_scraper ├── README.md ├── src │ ├── types │ │ ├── index.ts │ │ ├── db.ts │ │ ├── dependencies.ts │ │ ├── config.ts │ │ ├── article.ts │ │ └── services.ts │ ├── clients │ │ ├── github.ts │ │ ├── openAI.ts │ │ ├── instructor.ts │ │ ├── puppeteer.ts │ │ └── createClients.ts │ ├── utils │ │ ├── time.ts │ │ └── article.ts │ ├── services │ │ ├── articleProcessor.ts │ │ ├── createServices.ts │ │ ├── githubService.ts │ │ ├── articleContent.ts │ │ └── articleScraper.ts │ ├── index.ts │ ├── config │ │ └── config.ts │ ├── database │ │ └── mysql.ts │ └── workflow.ts ├── jest.config.ts ├── Makefile ├── Dockerfile ├── Dockerfile.dev ├── package.json └── tests │ └── workflow.test.ts ├── ollama ├── package-lock.json ├── Dockerfile.dev └── bin │ └── start-ollama.sh ├── nginx ├── Dockerfile.dev ├── development.conf └── production.conf ├── .github ├── dependabot.yml ├── workflows │ └── workflow.yml └── pull_request_template.md ├── .env.example ├── README.md ├── docker-compose.yml ├── Makefile ├── .gitignore └── docker-compose-dev.yml /backend/README.md: -------------------------------------------------------------------------------- 1 | # backend 2 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # frontend 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules/ -------------------------------------------------------------------------------- /rss/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod rss; 2 | -------------------------------------------------------------------------------- /rss/src/config/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | -------------------------------------------------------------------------------- /rss/src/errors/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod errors; 2 | -------------------------------------------------------------------------------- /rss/src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod article; 2 | -------------------------------------------------------------------------------- /rss/src/services/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod articles; 2 | -------------------------------------------------------------------------------- /hackernews_scraper/README.md: -------------------------------------------------------------------------------- 1 | # hackernews_scraper 2 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k-zehnder/gophersignal/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/images/photo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k-zehnder/gophersignal/HEAD/frontend/public/images/photo.jpg -------------------------------------------------------------------------------- /frontend/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k-zehnder/gophersignal/HEAD/frontend/public/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k-zehnder/gophersignal/HEAD/frontend/public/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k-zehnder/gophersignal/HEAD/frontend/public/apple-touch-icon.png -------------------------------------------------------------------------------- /ollama/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ollama", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | -------------------------------------------------------------------------------- /frontend/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k-zehnder/gophersignal/HEAD/frontend/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k-zehnder/gophersignal/HEAD/frontend/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /hackernews_scraper/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './article'; 2 | export * from './config'; 3 | export * from './services'; 4 | export * from './db'; 5 | -------------------------------------------------------------------------------- /frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-env', 4 | '@babel/preset-react', 5 | '@babel/preset-typescript', 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest', // Transform JS, JSX, TS, and TSX files 4 | }, 5 | testEnvironment: 'jsdom', // Use jsdom as the test environment 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /frontend/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. 7 | -------------------------------------------------------------------------------- /nginx/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # Use the official Nginx image as the base 2 | FROM nginx:latest 3 | 4 | # Copy the custom Nginx configuration file to the container 5 | COPY development.conf /etc/nginx/nginx.conf 6 | 7 | # Expose port 80 for the container 8 | EXPOSE 80 9 | 10 | # Start Nginx when the container starts 11 | CMD ["nginx", "-g", "daemon off;"] 12 | -------------------------------------------------------------------------------- /hackernews_scraper/src/clients/github.ts: -------------------------------------------------------------------------------- 1 | // Provides a GitHub API client using Octokit. 2 | 3 | import { Octokit } from '@octokit/rest'; 4 | 5 | type GitHubClient = Octokit; 6 | 7 | export const createGitHubClient = (token?: string): GitHubClient => { 8 | const authToken = token ?? process.env.GH_TOKEN; 9 | return new Octokit({ auth: authToken }); 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build the Next.js/React application 2 | FROM node:latest as build 3 | WORKDIR /app 4 | COPY ./package*.json ./ 5 | RUN npm install 6 | COPY ./ . 7 | RUN npm run build 8 | 9 | # Stage 2: Setup Nginx to serve the static files 10 | FROM nginx:latest 11 | RUN rm -rf /usr/share/nginx/html/* 12 | COPY --from=build /app/out /usr/share/nginx/html 13 | -------------------------------------------------------------------------------- /hackernews_scraper/src/clients/openAI.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai'; 2 | import config from '../config/config'; 3 | 4 | // Creates an OpenAI client configured for Ollama 5 | export const createOpenAIClient = () => { 6 | const openaiClient = new OpenAI({ 7 | apiKey: config.ollama.apiKey || 'ollama', 8 | baseURL: config.ollama.baseUrl, 9 | }); 10 | return openaiClient; 11 | }; 12 | -------------------------------------------------------------------------------- /hackernews_scraper/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | testMatch: ['/tests/**/*.test.ts'], 7 | collectCoverage: true, 8 | coverageDirectory: './coverage', 9 | moduleNameMapper: { 10 | '^@/(.*)$': '/src/$1', 11 | }, 12 | }; 13 | 14 | export default config; 15 | -------------------------------------------------------------------------------- /ollama/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # Use the official Ollama image as the base 2 | FROM ollama/ollama 3 | 4 | # Install curl 5 | RUN apt-get update && apt-get install -y curl 6 | 7 | # Copy the startup script into the container 8 | COPY bin/start-ollama.sh /start-ollama.sh 9 | 10 | # Make sure the script is executable 11 | RUN chmod +x /start-ollama.sh 12 | 13 | # Use the startup script as the entrypoint 14 | ENTRYPOINT ["/start-ollama.sh"] 15 | -------------------------------------------------------------------------------- /frontend/lib/theme.ts: -------------------------------------------------------------------------------- 1 | import { extendTheme } from "@mui/joy/styles"; 2 | 3 | // Create a custom theme by extending the default Material-UI theme. 4 | const theme = extendTheme({ 5 | // Define custom font family settings for the 'body' text. 6 | fontFamily: { 7 | body: "'Public Sans', var(--joy-fontFamily-fallback)", 8 | }, 9 | }); 10 | 11 | // Export the custom theme to make it available for use in the application. 12 | export default theme; 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'gomod' 4 | directory: '/backend' 5 | schedule: 6 | interval: 'weekly' 7 | 8 | - package-ecosystem: 'npm' 9 | directory: '/hackernews_scraper' 10 | schedule: 11 | interval: 'weekly' 12 | versioning-strategy: 'auto' 13 | 14 | - package-ecosystem: 'cargo' 15 | directory: '/rss' 16 | schedule: 17 | interval: 'weekly' 18 | versioning-strategy: 'auto' 19 | -------------------------------------------------------------------------------- /frontend/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # Use an official Node runtime as a parent image 2 | FROM node:latest 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Copy package.json and package-lock.json (or yarn.lock) 8 | COPY package*.json ./ 9 | 10 | # Install dependencies 11 | RUN npm install 12 | 13 | # Bundle app source 14 | COPY . . 15 | 16 | # Expose port 3000 to access server 17 | EXPOSE 3000 18 | 19 | # Command to run the app 20 | CMD ["npm", "run", "dev"] 21 | -------------------------------------------------------------------------------- /hackernews_scraper/src/types/db.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from 'mysql2/promise'; 2 | import { Article } from './article'; 3 | 4 | export interface DBClient { 5 | saveArticles: (articles: Article[]) => Promise; 6 | updateArticleSummary: (id: number, summary: string) => Promise; 7 | markArticleAsDead: (id: number) => Promise; 8 | markArticleAsDuplicate: (id: number) => Promise; 9 | closeDatabaseConnection: () => Promise; 10 | connection: Connection; 11 | } 12 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | next-env.d.ts 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | .env* 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | .vercel 28 | /test-results/ 29 | /playwright-report/ 30 | /blob-report/ 31 | /playwright/.cache/ 32 | -------------------------------------------------------------------------------- /rss/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: 3 | @echo "Building RSS Docker image..." 4 | docker build -t $(RSS_IMAGE_TAG) . 5 | 6 | .PHONY: push 7 | push: 8 | @echo "Pushing RSS Docker image..." 9 | docker push $(RSS_IMAGE_TAG) 10 | 11 | .PHONY: run 12 | run: 13 | @echo "Starting RSS service..." 14 | docker compose up -d rss 15 | 16 | .PHONY: test 17 | test: 18 | @echo "Running Rust tests with cargo..." 19 | cargo test 20 | 21 | .PHONY: pull 22 | pull: 23 | @echo "Pulling RSS Docker image..." 24 | docker pull $(RSS_IMAGE_TAG) 25 | -------------------------------------------------------------------------------- /hackernews_scraper/src/clients/instructor.ts: -------------------------------------------------------------------------------- 1 | import Instructor from '@instructor-ai/instructor'; 2 | import { createOpenAIClient } from './openAI'; 3 | 4 | // Wraps the OpenAI client with the Instructor library 5 | export const createInstructorClient = ( 6 | openaiClient: ReturnType 7 | ) => { 8 | const instructorClient = Instructor({ 9 | client: openaiClient, 10 | mode: 'JSON', 11 | }); 12 | return instructorClient; 13 | }; 14 | 15 | export type InstructorClient = ReturnType; 16 | -------------------------------------------------------------------------------- /rss/.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled artifacts 2 | /target/ 3 | # Dependency directory 4 | /vendor/ 5 | /**/*.rs.bk 6 | 7 | # Generated by cargo 8 | **/*.rs.bk 9 | Cargo.lock 10 | 11 | # macOS 12 | .DS_Store 13 | 14 | # Linux 15 | *~ 16 | 17 | # Windows 18 | Thumbs.db 19 | ehthumbs.db 20 | 21 | # VS Code settings 22 | .vscode/ 23 | 24 | # IntelliJ IDEA 25 | .idea/ 26 | 27 | # JetBrains IDEs 28 | *.iml 29 | 30 | # Rust language server configuration 31 | .rls/ 32 | 33 | # Binary files 34 | *.exe 35 | *.out 36 | *.app 37 | 38 | # Debug 39 | debug.rs 40 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | // next.config.js 2 | /** 3 | * @type {import('next').NextConfig} 4 | */ 5 | const nextConfig = { 6 | output: 'export', 7 | 8 | // Optional: Change links `/me` -> `/me/` and emit `/me.html` -> `/me/index.html` 9 | // trailingSlash: true, 10 | 11 | // Optional: Prevent automatic `/me` -> `/me/`, instead preserve `href` 12 | // skipTrailingSlashRedirect: true, 13 | 14 | // Optional: Change the output directory `out` -> `dist` 15 | // distDir: 'dist', 16 | 17 | swcMinify: true, // Enables SWC minification 18 | }; 19 | 20 | module.exports = nextConfig; 21 | -------------------------------------------------------------------------------- /hackernews_scraper/src/utils/time.ts: -------------------------------------------------------------------------------- 1 | export const createTimeUtil = () => { 2 | // Returns a formatted date (YYYY-MM-DD) with the given offset (in days) 3 | const getFormattedDate = (offset: number = 0): string => { 4 | const date = new Date(); 5 | date.setDate(date.getDate() + offset); 6 | return date.toISOString().split('T')[0]; 7 | }; 8 | 9 | return { 10 | today: getFormattedDate(0), 11 | yesterday: getFormattedDate(-1), 12 | dayBeforeYesterday: getFormattedDate(-2), 13 | getDate: getFormattedDate, 14 | }; 15 | }; 16 | 17 | export type TimeUtil = ReturnType; 18 | -------------------------------------------------------------------------------- /rss/src/config/config.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | #[derive(Clone)] 4 | pub struct AppConfig { 5 | pub port: String, 6 | pub api_url: String, 7 | } 8 | 9 | impl AppConfig { 10 | pub fn from_env() -> Self { 11 | // Only load .env if not running tests. 12 | #[cfg(not(test))] 13 | { 14 | dotenv::dotenv().ok(); 15 | } 16 | Self { 17 | port: env::var("RSS_PORT").unwrap_or_else(|_| "9090".to_string()), 18 | api_url: env::var("API_URL") 19 | .unwrap_or_else(|_| "https://gophersignal.com/api/v1/articles".to_string()), 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: 3 | @echo "Building frontend Docker image..." 4 | docker build -t $(FRONTEND_IMAGE_TAG) . 5 | 6 | .PHONY: push 7 | push: 8 | @echo "Pushing frontend Docker image..." 9 | docker push $(FRONTEND_IMAGE_TAG) 10 | 11 | .PHONY: run 12 | run: 13 | @echo "Starting frontend service..." 14 | docker compose up -d frontend 15 | 16 | .PHONY: test 17 | test: 18 | @echo "Installing testing dependencies..." 19 | npm install jest jest-environment-jsdom 20 | @echo "Running frontend tests..." 21 | npm run test 22 | 23 | .PHONY: pull 24 | pull: 25 | @echo "Pulling frontend Docker image..." 26 | docker pull $(FRONTEND_IMAGE_TAG) 27 | -------------------------------------------------------------------------------- /ollama/bin/start-ollama.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Start the Ollama server in the background 4 | ollama serve & 5 | 6 | # Wait for the Ollama server to be available 7 | echo "Starting Ollama server..." 8 | while ! curl -s http://localhost:11434 > /dev/null; do 9 | echo "Waiting for Ollama server to be ready..." 10 | sleep 5 11 | done 12 | 13 | # Pull the required Ollama model 14 | echo "Pulling the required Ollama model..." 15 | ollama pull llama3:instruct 16 | echo "Model pulled successfully." 17 | 18 | # Create a flag file to signal that the model has been pulled 19 | touch /tmp/ollama_model_ready 20 | 21 | # Keep the container running 22 | tail -f /dev/null 23 | -------------------------------------------------------------------------------- /hackernews_scraper/src/types/dependencies.ts: -------------------------------------------------------------------------------- 1 | import { DBClient } from './db'; 2 | import { TimeUtil } from '../utils/time'; 3 | import { InstructorClient } from '../clients/instructor'; 4 | import { BrowserClient } from '../clients/puppeteer'; 5 | import { 6 | Scraper, 7 | ArticleProcessor, 8 | ArticleSummarizer, 9 | GitHubService, 10 | } from './services'; 11 | 12 | export interface Dependencies { 13 | db: DBClient; 14 | browser: BrowserClient; 15 | timeUtil: TimeUtil; 16 | instructorClient: InstructorClient; 17 | scraper: Scraper; 18 | articleProcessor: ArticleProcessor; 19 | articleSummarizer: ArticleSummarizer; 20 | githubService: GitHubService; 21 | } 22 | -------------------------------------------------------------------------------- /backend/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: 3 | @echo "Building backend Docker image..." 4 | docker build -t $(BACKEND_IMAGE_TAG) . 5 | 6 | .PHONY: push 7 | push: 8 | @echo "Pushing backend Docker image..." 9 | docker push $(BACKEND_IMAGE_TAG) 10 | 11 | .PHONY: run 12 | run: 13 | @echo "Starting backend service..." 14 | docker compose up -d backend 15 | 16 | .PHONY: test 17 | test: 18 | @echo "Running Go tests with coverage..." 19 | go test -v -coverprofile=coverage.out ./... 20 | @echo "Generating coverage report..." 21 | go tool cover -html=coverage.out -o coverage.html 22 | 23 | .PHONY: pull 24 | pull: 25 | @echo "Pulling backend Docker image..." 26 | docker pull $(BACKEND_IMAGE_TAG) 27 | -------------------------------------------------------------------------------- /rss/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gophersignal-rss" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | dotenv = "0.15" 8 | tokio = { version = "1", features = ["full"] } 9 | axum = "0.8" 10 | reqwest = { version = "0.12", features = ["json"] } 11 | rss = "2.0" 12 | serde = { version = "1.0", features = ["derive"] } 13 | serde_json = "1.0" 14 | chrono = "0.4" 15 | async-trait = "0.1" 16 | serial_test = "3.2" 17 | sqlx = { version = "0.8", features = ["mysql", "runtime-tokio-native-tls", "macros", "chrono"] } 18 | thiserror = "2.0" 19 | sha2 = "0.10" 20 | log = "0.4" 21 | env_logger = "0.11" 22 | url = "2.3.1" 23 | htmlescape = "0.3.1" 24 | 25 | [[bin]] 26 | name = "rss" 27 | path = "src/main.rs" 28 | -------------------------------------------------------------------------------- /hackernews_scraper/src/types/config.ts: -------------------------------------------------------------------------------- 1 | export interface MySQLConfig { 2 | host: string; 3 | port: number; 4 | user: string; 5 | password: string; 6 | database: string; 7 | } 8 | 9 | export interface OllamaConfig { 10 | baseUrl: string; 11 | model: string; 12 | apiKey?: string; 13 | maxContentLength: number; 14 | maxSummaryLength: number; 15 | numCtx: number; // Context window size for Ollama model, read from OLLAMA_CONTEXT_LENGTH env var 16 | } 17 | 18 | export interface GitHubConfig { 19 | token?: string; 20 | owner: string; 21 | repo: string; 22 | branch: string; 23 | } 24 | 25 | export interface Config { 26 | mysql: MySQLConfig; 27 | ollama: OllamaConfig; 28 | github: GitHubConfig; 29 | } 30 | -------------------------------------------------------------------------------- /frontend/lib/siteMetaData.ts: -------------------------------------------------------------------------------- 1 | export const siteMetaData = { 2 | title: "Gopher Signal", 3 | author: "Mr. Ritchie", 4 | description: "Discover the latest in technology with Gopher Signal, your source for concise summaries of cutting-edge articles from HackerNews.com. Powered by AI technologies like ChatGPT and HuggingFace, Gopher Signal distills complex tech insights into easy-to-digest updates, saving you time while keeping you informed. Join us for tech news, insights, and discussions, all optimized for SEO through the use of AI technology.", 5 | language: "en-us", 6 | siteUrl: "https://gophersignal.com", 7 | image: "/images/photo.jpg", 8 | ogImage: "/images/photo.jpg", 9 | twImage: "/images/photo.jpg", 10 | locale: "en-US", 11 | }; 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Frontend 2 | NEXT_PUBLIC_ENV=development 3 | 4 | # Backend 5 | GO_ENV=development 6 | SERVER_ADDRESS=0.0.0.0:8080 7 | CACHE_MAX_AGE=1200 # 20 minutes 8 | 9 | # MySQL 10 | MYSQL_HOST=mysql 11 | MYSQL_PORT=3306 12 | MYSQL_DATABASE=gophersignal 13 | MYSQL_USER=user 14 | MYSQL_PASSWORD=password 15 | MYSQL_ROOT_PASSWORD=password 16 | 17 | # Ollama 18 | OLLAMA_BASE_URL=http://ollama:11434/api 19 | OLLAMA_MODEL=qwen3:8b 20 | OLLAMA_CONTEXT_LENGTH=8192 21 | 22 | # RSS 23 | RSS_PORT=9090 24 | API_URL=http://backend:8080/api/v1/articles 25 | DATABASE_URL=mysql://user:password@host:3306/gophersignal 26 | 27 | # GitHub (for commit‐hash resolution) 28 | GH_TOKEN=your_personal_access_token 29 | GITHUB_OWNER=k-zehnder 30 | GITHUB_REPO=gophersignal 31 | GITHUB_BRANCH=main 32 | -------------------------------------------------------------------------------- /frontend/lib/stringUtils.ts: -------------------------------------------------------------------------------- 1 | // processSummary function processes a summary object and returns a string. 2 | export const processSummary = ( 3 | summary: { String: string; Valid: boolean } | null 4 | ): string => { 5 | return summary?.Valid && summary.String.trim() !== '' 6 | ? summary.String 7 | : 'No summary available'; 8 | }; 9 | 10 | // formatDate function takes a date string and formats it to a human-readable date. 11 | export const formatDate = (dateStr: string): string => { 12 | if (!dateStr) { 13 | return 'Date not available'; 14 | } 15 | const date = new Date(dateStr); 16 | return isNaN(date.getTime()) 17 | ? 'Invalid Date' 18 | : date.toLocaleDateString(undefined, { 19 | year: 'numeric', 20 | month: 'long', 21 | day: 'numeric', 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /frontend/components/ArticleList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useArticles from '../hooks/useArticles'; 3 | import ArticleListItem from './ArticleListItem'; 4 | import List from '@mui/joy/List'; 5 | import { Article } from '../types'; 6 | 7 | function ArticleList() { 8 | const articles: Article[] = useArticles(); 9 | 10 | if (!Array.isArray(articles)) { 11 | return
No articles available
; 12 | } 13 | 14 | return ( 15 | 16 | {/* Map through the articles and render each article as an 'ArticleListItem' component. */} 17 | {articles.map((article: Article) => ( 18 | 19 | ))} 20 | 21 | ); 22 | } 23 | 24 | export default ArticleList; 25 | -------------------------------------------------------------------------------- /backend/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE IF NOT EXISTS gophersignal; 2 | USE gophersignal; 3 | 4 | CREATE TABLE IF NOT EXISTS articles ( 5 | id INT AUTO_INCREMENT PRIMARY KEY, 6 | hn_id INT NOT NULL DEFAULT 0, 7 | title VARCHAR(255) NOT NULL, 8 | link VARCHAR(512) NOT NULL, 9 | article_rank INT NOT NULL, 10 | content TEXT, 11 | summary VARCHAR(2000), 12 | source VARCHAR(100) NOT NULL, 13 | upvotes INT DEFAULT 0, 14 | comment_count INT DEFAULT 0, 15 | comment_link VARCHAR(255), 16 | flagged BOOLEAN NOT NULL DEFAULT FALSE, 17 | dead BOOLEAN NOT NULL DEFAULT FALSE, 18 | dupe BOOLEAN NOT NULL DEFAULT FALSE, 19 | commit_hash VARCHAR(7) NOT NULL DEFAULT '', 20 | model_name VARCHAR(100) NOT NULL DEFAULT '', 21 | created_at TIMESTAMP NOT NULL, 22 | updated_at TIMESTAMP NOT NULL 23 | ); 24 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": false, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noEmit": true, 13 | "incremental": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "target": "ES2017", 21 | "plugins": [ 22 | { 23 | "name": "next" 24 | } 25 | ] 26 | }, 27 | "include": [ 28 | "**/*.ts", 29 | "**/*.tsx", 30 | "next-env.d.ts", 31 | "next.config.js", 32 | ".next/types/**/*.ts" 33 | ], 34 | "exclude": [ 35 | "node_modules" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /rss/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # Use Rust slim image for building the project 2 | FROM rust:slim AS dev 3 | 4 | # Set the working directory 5 | WORKDIR /app 6 | 7 | # Install necessary tools and OpenSSL dependencies 8 | RUN apt-get update && \ 9 | apt-get install -y build-essential curl vim pkg-config libssl-dev && \ 10 | apt-get clean 11 | 12 | # Copy only Cargo files first for caching dependencies 13 | COPY Cargo.toml Cargo.lock ./ 14 | 15 | # Fetch dependencies and generate the Cargo.lock file if missing 16 | RUN cargo fetch || (echo "Cargo.lock missing, generating it..." && cargo generate-lockfile) 17 | 18 | # Copy the entire project 19 | COPY . . 20 | 21 | # Build the project 22 | RUN cargo build --release 23 | 24 | # Expose the correct development port 25 | EXPOSE 9090 26 | 27 | # Run the Rust binary directly (No hot-reloading) 28 | CMD ["cargo", "run", "--release"] 29 | -------------------------------------------------------------------------------- /hackernews_scraper/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: 3 | @echo "Building HackerNews scraper Docker image..." 4 | docker build -t $(HACKERNEWS_SCRAPER_IMAGE_TAG) . 5 | 6 | .PHONY: test 7 | test: 8 | @echo "Running tests for HackerNews scraper..." 9 | docker run --rm $(HACKERNEWS_SCRAPER_IMAGE_TAG) npm test 10 | 11 | .PHONY: push 12 | push: 13 | @echo "Pushing HackerNews scraper Docker image..." 14 | docker push $(HACKERNEWS_SCRAPER_IMAGE_TAG) 15 | 16 | .PHONY: run 17 | run: 18 | @echo "Starting HackerNews scraper service..." 19 | docker compose up -d hackernews_scraper 20 | 21 | .PHONY: pull 22 | pull: 23 | @echo "Pulling hackernews_scraper Docker image..." 24 | docker pull $(HACKERNEWS_SCRAPER_IMAGE_TAG) 25 | 26 | .PHONY: scrape 27 | scrape: 28 | @echo "Running HackerNews scraper..." 29 | docker exec -it $(shell docker ps -qf "name=hackernews_scraper") npm run start 30 | -------------------------------------------------------------------------------- /hackernews_scraper/src/types/article.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | // Defines the Article interface used for Hacker News articles 4 | export interface Article { 5 | title: string; 6 | link: string; 7 | hnId: number; 8 | articleRank: number; 9 | flagged: boolean; 10 | dead: boolean; 11 | dupe: boolean; 12 | upvotes: number; 13 | commentCount: number; 14 | commentLink: string; 15 | content?: string; 16 | summary?: string; 17 | category?: string; 18 | commitHash: string; 19 | modelName: string; 20 | } 21 | 22 | export const SummaryResponseSchema = z.object({ 23 | summary: z 24 | .preprocess((raw) => { 25 | if (Array.isArray(raw)) { 26 | // Join array of lines into one string 27 | return (raw as string[]).join('\n'); 28 | } 29 | return raw; 30 | }, z.string()) 31 | .optional(), 32 | _meta: z.any().optional(), 33 | }); 34 | -------------------------------------------------------------------------------- /backend/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # Use the official Go image as the base image 2 | FROM golang:latest 3 | 4 | # Install necessary packages 5 | RUN apt-get update && apt-get install -y \ 6 | default-mysql-client \ 7 | iproute2 && \ 8 | rm -rf /var/lib/apt/lists/* 9 | 10 | # Set the working directory for the application 11 | WORKDIR /app 12 | 13 | # Install swag CLI for generating Swagger docs 14 | RUN go install github.com/swaggo/swag/cmd/swag@latest 15 | 16 | # Copy go mod and sum files 17 | COPY go.mod go.sum ./ 18 | 19 | # Download all dependencies 20 | RUN go mod download 21 | 22 | # Copy the source from the current directory to the Working Directory inside the container 23 | COPY . . 24 | 25 | # Generate Swagger docs 26 | RUN swag init 27 | 28 | # Ensure entrypoint.sh is executable 29 | RUN chmod +x /app/bin/entrypoint.sh 30 | 31 | # Expose port 8080 32 | EXPOSE 8080 33 | 34 | # Run the entrypoint script 35 | ENTRYPOINT ["/app/bin/entrypoint.sh"] 36 | -------------------------------------------------------------------------------- /rss/src/main.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | mod errors; 3 | mod models; 4 | mod routes; 5 | mod services; 6 | 7 | use axum::{routing::get, Extension, Router}; 8 | use config::config::AppConfig; 9 | use errors::errors::AppError; 10 | use routes::rss::generate_rss_feed; 11 | use services::articles::HttpArticlesClient; 12 | use std::net::SocketAddr; 13 | 14 | #[tokio::main] 15 | async fn main() -> Result<(), AppError> { 16 | let config = AppConfig::from_env(); 17 | let client = HttpArticlesClient; 18 | 19 | let app = Router::new() 20 | .route("/rss", get(generate_rss_feed::)) 21 | .layer(Extension(config.clone())) 22 | .layer(Extension(client)); 23 | 24 | println!("Server running on port: {}", config.port); 25 | 26 | let addr: SocketAddr = format!("0.0.0.0:{}", config.port).parse()?; 27 | let listener = tokio::net::TcpListener::bind(addr).await?; 28 | axum::serve(listener, app).await?; 29 | 30 | Ok(()) 31 | } 32 | -------------------------------------------------------------------------------- /hackernews_scraper/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Node runtime as the base image 2 | FROM node:latest 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Copy package.json and package-lock.json 8 | COPY package*.json ./ 9 | 10 | # Install Node dependencies and additional system dependencies needed for scraping 11 | RUN npm install ts-node && apt-get update && apt-get install -y \ 12 | libnss3 \ 13 | libatk1.0-0 \ 14 | libatk-bridge2.0-0 \ 15 | libcups2 \ 16 | libdrm2 \ 17 | libxkbcommon0 \ 18 | libxcomposite1 \ 19 | libxrandr2 \ 20 | libgbm1 \ 21 | libasound2 \ 22 | libpangocairo-1.0-0 \ 23 | libpango-1.0-0 \ 24 | libcairo2 \ 25 | libatspi2.0-0 \ 26 | libgtk-3-0 \ 27 | libdbus-1-3 28 | 29 | # Bundle app source 30 | COPY . . 31 | 32 | # Keep the container running without doing anything 33 | # This facilitates manually triggering the scraper as needed 34 | CMD ["tail", "-f", "/dev/null"] 35 | -------------------------------------------------------------------------------- /hackernews_scraper/src/types/services.ts: -------------------------------------------------------------------------------- 1 | import { Article } from './article'; 2 | import { ArticleHelpers } from '../utils/article'; 3 | 4 | export interface Scraper { 5 | scrapeTopStories: (numPages?: number) => Promise; 6 | scrapeFront: (numPages?: number) => Promise; 7 | scrapeFrontForDay: (day: string) => Promise; 8 | } 9 | 10 | export interface ContentFetcher { 11 | fetchArticleContent: (url: string) => Promise; 12 | } 13 | 14 | export interface ArticleProcessor { 15 | scrapeTopStories: (numPages?: number) => Promise; 16 | scrapeFrontForDay: (day: string) => Promise; 17 | processArticles: (articles: Article[]) => Promise; 18 | helpers: ArticleHelpers; 19 | } 20 | 21 | export interface ArticleSummarizer { 22 | summarizeArticles: (articles: Required
[]) => Promise; 23 | } 24 | 25 | export interface GitHubService { 26 | getCommitHash(): Promise; 27 | } 28 | -------------------------------------------------------------------------------- /hackernews_scraper/src/clients/puppeteer.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import puppeteer from 'puppeteer-extra'; 3 | import StealthPlugin from 'puppeteer-extra-plugin-stealth'; 4 | import { Browser } from 'puppeteer'; 5 | 6 | // Creates and configures a Puppeteer browser client 7 | export const createBrowserClient = async () => { 8 | puppeteer.use(StealthPlugin()); 9 | const browser = await puppeteer.launch({ 10 | headless: true, 11 | args: [ 12 | '--no-sandbox', 13 | '--disable-setuid-sandbox', 14 | '--disable-dev-shm-usage', 15 | '--disable-cache', 16 | '--disk-cache-size=0', 17 | '--incognito', 18 | '--disable-gpu', 19 | ], 20 | protocolTimeout: 30000, 21 | }); 22 | 23 | process.on('exit', async () => { 24 | await browser.close(); 25 | }); 26 | 27 | return browser; 28 | }; 29 | 30 | export const BrowserClientSchema = z.instanceof(Browser); 31 | export type BrowserClient = z.infer; 32 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD Pipeline 2 | 3 | on: 4 | push: 5 | branches: [main, staging] 6 | pull_request: 7 | branches: [main] 8 | 9 | permissions: 10 | contents: read 11 | packages: write 12 | actions: write 13 | 14 | jobs: 15 | build-test-push: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout Code 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v3 24 | 25 | - name: Login to Docker Hub 26 | uses: docker/login-action@v3 27 | with: 28 | username: ${{ secrets.DOCKERHUB_USERNAME }} 29 | password: ${{ secrets.DOCKERHUB_TOKEN }} 30 | 31 | - name: Build, Test, and Push All Components 32 | run: make all 33 | 34 | - name: Upload Go coverage report 35 | uses: actions/upload-artifact@v4 36 | with: 37 | name: coverage-report 38 | path: backend/coverage.html 39 | -------------------------------------------------------------------------------- /hackernews_scraper/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # Use an official Node runtime as the base image 2 | FROM node:latest 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Copy package.json and package-lock.json 8 | COPY package*.json ./ 9 | 10 | # Install dependencies, including ts-node globally and required system libraries 11 | RUN npm install -g ts-node typescript && \ 12 | npm install && \ 13 | apt-get update && apt-get install -y \ 14 | libnss3 \ 15 | libatk1.0-0 \ 16 | libatk-bridge2.0-0 \ 17 | libcups2 \ 18 | libdrm2 \ 19 | libxkbcommon0 \ 20 | libxcomposite1 \ 21 | libxrandr2 \ 22 | libgbm1 \ 23 | libasound2 \ 24 | libpangocairo-1.0-0 \ 25 | libpango-1.0-0 \ 26 | libcairo2 \ 27 | libatspi2.0-0 \ 28 | libgtk-3-0 \ 29 | libdbus-1-3 && \ 30 | rm -rf /var/lib/apt/lists/* 31 | 32 | # Bundle app source 33 | COPY . . 34 | 35 | # Keep the container running without doing anything 36 | # This facilitates manually triggering the scraper as needed 37 | CMD ["tail", "-f", "/dev/null"] 38 | -------------------------------------------------------------------------------- /backend/main.go: -------------------------------------------------------------------------------- 1 | // Package main is the entry point for the GopherSignal API server. 2 | // @title GopherSignal API 3 | // @description API server for the GopherSignal application. 4 | // @version 1 5 | // @BasePath /api/v1 6 | package main 7 | 8 | import ( 9 | "log" 10 | 11 | "github.com/k-zehnder/gophersignal/backend/config" 12 | "github.com/k-zehnder/gophersignal/backend/internal/api/router" 13 | "github.com/k-zehnder/gophersignal/backend/internal/api/server" 14 | "github.com/k-zehnder/gophersignal/backend/internal/store" 15 | ) 16 | 17 | // main initializes and launches the API server. 18 | func main() { 19 | // Load server configuration 20 | cfg := config.NewConfig() 21 | 22 | // Initialize the database store 23 | store, err := store.NewMySQLStore(cfg.DataSourceName) 24 | if err != nil { 25 | log.Fatalf("Failed to create store: %v", err) 26 | } 27 | 28 | // Create the router 29 | router := router.NewRouter(store, cfg) 30 | 31 | // Start the HTTP server 32 | srv := server.StartServer(cfg.ServerAddress, router) 33 | defer server.GracefulShutdown(srv) 34 | } 35 | -------------------------------------------------------------------------------- /rss/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Rust slim image for building the project 2 | FROM rust:slim AS builder 3 | 4 | # Set the working directory 5 | WORKDIR /app 6 | 7 | # Install necessary build tools, OpenSSL dependencies, and pkg-config 8 | RUN apt-get update && \ 9 | apt-get install -y build-essential curl vim pkg-config libssl-dev && \ 10 | apt-get clean 11 | 12 | # Copy only Cargo files first for caching dependencies 13 | COPY Cargo.toml ./ 14 | 15 | # If Cargo.lock is missing, create it after copying Cargo.toml 16 | RUN cargo fetch || (echo "Cargo.lock missing, generating it..." && cargo generate-lockfile) 17 | 18 | # Now copy the full source code 19 | COPY . . 20 | 21 | # Build the project in release mode 22 | RUN cargo build --release 23 | 24 | # Use a distroless image for runtime 25 | FROM gcr.io/distroless/cc 26 | 27 | # Set the working directory 28 | WORKDIR /app 29 | 30 | # Copy the compiled binary from the builder stage 31 | COPY --from=builder /app/target/release/rss . 32 | 33 | # Expose the application port 34 | EXPOSE 9090 35 | 36 | # Run the compiled binary 37 | CMD ["./rss"] 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GopherSignal 2 | 3 | [![CI/CD Pipeline](https://github.com/k-zehnder/gophersignal/actions/workflows/workflow.yml/badge.svg)](https://github.com/k-zehnder/gophersignal/actions/workflows/workflow.yml) 4 | 5 | ## Quickstart 6 | 7 | 1. **Clone the Repository:** 8 | 9 | ```bash 10 | git clone https://github.com/k-zehnder/gophersignal.git 11 | cd gophersignal 12 | ``` 13 | 14 | 2. **Set Up Environment:** 15 | 16 | Copy the example environment file and update any necessary values: 17 | 18 | ```bash 19 | cp .env.example .env 20 | ``` 21 | 22 | 3. **Start Development Environment:** 23 | 24 | Build and start all services: 25 | 26 | ```bash 27 | make dev 28 | ``` 29 | 30 | 4. **Run the Scraper:** 31 | 32 | Populate the database by running the scraper: 33 | 34 | ```bash 35 | make scrape 36 | ``` 37 | 38 | 5. **Access the Application:** 39 | 40 | - **Frontend:** [http://localhost:3000](http://localhost:3000) 41 | - **API Documentation (Swagger UI):** [http://localhost:8080/swagger/index.html](http://localhost:8080/swagger/index.html) 42 | -------------------------------------------------------------------------------- /frontend/components/Description.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Typography from '@mui/joy/Typography'; 3 | import Box from '@mui/joy/Box'; 4 | import Link from '@mui/joy/Link'; 5 | 6 | // Description component provides a welcome message and a brief explanation. 7 | function Description() { 8 | return ( 9 | 10 | {/* Display a heading with a welcome message. */} 11 | 12 | Welcome to Gopher Signal 13 | 14 | 15 | {/* Display a paragraph with an explanation of Gopher Signal. */} 16 | 17 | Gopher Signal uses smart technology to quickly summarize important points from{" "} 18 | {/* Create a link to Hacker News. */} 19 | 20 | Hacker News 21 | {" "} 22 | articles, giving you brief and useful updates. 23 | 24 | 25 | ); 26 | } 27 | 28 | export default Description; 29 | -------------------------------------------------------------------------------- /hackernews_scraper/src/utils/article.ts: -------------------------------------------------------------------------------- 1 | import { Article } from '../types'; 2 | 3 | const createArticleHelpers = () => { 4 | const categorizeArticles = (articles: Article[]) => { 5 | return articles.reduce( 6 | (acc, article) => { 7 | if (article.flagged) acc.flagged.push(article); 8 | if (article.dead) acc.dead.push(article); 9 | if (article.dupe) acc.dupe.push(article); 10 | return acc; 11 | }, 12 | { flagged: [] as Article[], dead: [] as Article[], dupe: [] as Article[] } 13 | ); 14 | }; 15 | 16 | const getTopArticlesWithContent = ( 17 | processedArticles: Article[], 18 | topArticles: Article[] 19 | ): Required
[] => { 20 | return processedArticles.filter( 21 | (article): article is Required
=> 22 | topArticles.some((top) => top.link === article.link) && 23 | article.content !== undefined 24 | ); 25 | }; 26 | 27 | return { 28 | categorizeArticles, 29 | getTopArticlesWithContent, 30 | }; 31 | }; 32 | 33 | export type ArticleHelpers = ReturnType; 34 | 35 | export default createArticleHelpers; 36 | -------------------------------------------------------------------------------- /backend/bin/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -euo pipefail 3 | 4 | : "${MYSQL_HOST:=host.docker.internal}" 5 | : "${MYSQL_PORT:=3306}" 6 | : "${MYSQL_DATABASE:=gophersignal}" 7 | : "${MYSQL_USER:=user}" 8 | : "${MYSQL_PASSWORD:=password}" 9 | : "${GO_ENV:=production}" 10 | 11 | echo "Using MySQL ${MYSQL_HOST}:${MYSQL_PORT} db=${MYSQL_DATABASE} user=${MYSQL_USER}" 12 | echo "Starting database initialization..." 13 | 14 | export MYSQL_PWD="${MYSQL_PASSWORD}" 15 | until mysqladmin --protocol=tcp -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" ping >/dev/null 2>&1; do 16 | echo "Waiting for MySQL..." 17 | sleep 5 18 | done 19 | echo "MySQL is ready." 20 | 21 | if [ -f /app/schema.sql ]; then 22 | echo "Applying schema..." 23 | mysql --protocol=tcp -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" "$MYSQL_DATABASE" < /app/schema.sql 24 | echo "Schema applied." 25 | else 26 | echo "No /app/schema.sql found; skipping schema apply." 27 | fi 28 | 29 | unset MYSQL_PWD 30 | echo "Database initialization completed." 31 | echo "Starting Go application..." 32 | if [ "${GO_ENV}" = "development" ]; then 33 | exec go run main.go 34 | else 35 | exec ./main 36 | fi 37 | -------------------------------------------------------------------------------- /backend/internal/api/server/server.go: -------------------------------------------------------------------------------- 1 | // Package server sets up the web server and routing logic for the GopherSignal application. 2 | package server 3 | 4 | import ( 5 | "context" 6 | "log" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | "time" 12 | ) 13 | 14 | // StartServer launches an HTTP server on the specified address. 15 | func StartServer(addr string, handler http.Handler) *http.Server { 16 | server := &http.Server{ 17 | Addr: addr, 18 | Handler: handler, 19 | } 20 | go func() { 21 | if err := server.ListenAndServe(); err != http.ErrServerClosed { 22 | log.Fatalf("Failed to start server: %v", err) 23 | } 24 | }() 25 | return server 26 | } 27 | 28 | // GracefulShutdown handles server shutdown on receiving interrupt or termination signals. 29 | func GracefulShutdown(server *http.Server) { 30 | quit := make(chan os.Signal, 1) 31 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 32 | <-quit // Blocks until a signal is received 33 | 34 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 35 | defer cancel() 36 | if err := server.Shutdown(ctx); err != nil { 37 | log.Fatalf("Failed to shutdown server: %v", err) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /hackernews_scraper/src/clients/createClients.ts: -------------------------------------------------------------------------------- 1 | // Initializes infrastructure clients. 2 | 3 | import { createBrowserClient } from '../clients/puppeteer'; 4 | import { createMySqlClient } from '../database/mysql'; 5 | import { createOpenAIClient } from '../clients/openAI'; 6 | import { createInstructorClient } from '../clients/instructor'; 7 | import { createGitHubClient } from './github'; 8 | import createArticleHelpers from '../utils/article'; 9 | import { createTimeUtil } from '../utils/time'; 10 | import { Config } from '../types/config'; 11 | 12 | export const createClients = async (config: Config) => { 13 | const browser = await createBrowserClient(); 14 | const db = await createMySqlClient(config); 15 | const openaiClient = createOpenAIClient(); 16 | const instructorClient = createInstructorClient(openaiClient); 17 | const githubClient = createGitHubClient(); 18 | const articleHelpers = createArticleHelpers(); 19 | const timeUtil = createTimeUtil(); 20 | 21 | return { 22 | browser, 23 | db, 24 | openaiClient, 25 | instructorClient, 26 | githubClient, 27 | articleHelpers, 28 | timeUtil, 29 | }; 30 | }; 31 | 32 | export type Clients = Awaited>; 33 | -------------------------------------------------------------------------------- /hackernews_scraper/src/services/articleProcessor.ts: -------------------------------------------------------------------------------- 1 | import { Article, Scraper, ContentFetcher } from '../types'; 2 | import { ArticleHelpers } from '../utils/article'; 3 | 4 | const createArticleProcessor = ( 5 | scraper: Scraper, 6 | contentFetcher: ContentFetcher, 7 | helpers: ArticleHelpers 8 | ) => { 9 | // Scrapes top stories using page-number based pagination or next-button logic 10 | const scrapeTopStories = async (numPages?: number): Promise => { 11 | return await scraper.scrapeTopStories(numPages); 12 | }; 13 | 14 | // Processes articles by fetching full content 15 | const processArticles = async (articles: Article[]): Promise => { 16 | for (const article of articles) { 17 | try { 18 | article.content = await contentFetcher.fetchArticleContent( 19 | article.link 20 | ); 21 | } catch (error) { 22 | console.error(`Error processing article at ${article.link}:`, error); 23 | } 24 | await new Promise((resolve) => setTimeout(resolve, 1000)); 25 | } 26 | return articles; 27 | }; 28 | 29 | return { 30 | scrapeTopStories, 31 | processArticles, 32 | helpers, 33 | }; 34 | }; 35 | 36 | export { createArticleProcessor }; 37 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build the Go application 2 | FROM golang:latest AS build 3 | WORKDIR /app 4 | 5 | # Install swag CLI for generating Swagger docs 6 | RUN go install github.com/swaggo/swag/cmd/swag@latest 7 | 8 | # Copy the Go module files and download dependencies 9 | COPY go.mod go.sum ./ 10 | RUN go mod download 11 | 12 | # Copy the rest of the application code 13 | COPY . ./ 14 | 15 | # Generate Swagger docs and build the application 16 | RUN swag init && CGO_ENABLED=0 go build -o main . 17 | 18 | # Verify Swagger docs generation 19 | RUN test -f docs/swagger.json && test -f docs/swagger.yaml 20 | 21 | # Stage 2: Setup the application in a smaller container 22 | FROM debian:latest 23 | WORKDIR /root/ 24 | 25 | # Install MySQL client and iproute2 for database initialization 26 | RUN apt-get update && apt-get install -y default-mysql-client iproute2 27 | 28 | # Copy the built application binary and entrypoint script from the build stage 29 | COPY --from=build /app/main . 30 | COPY --from=build /app/bin/entrypoint.sh . 31 | 32 | # Ensure entrypoint.sh is executable 33 | RUN chmod +x /root/entrypoint.sh 34 | 35 | # Expose the application port 36 | EXPOSE 8080 37 | 38 | # Run the entrypoint script 39 | ENTRYPOINT ["/root/entrypoint.sh"] 40 | 41 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/k-zehnder/gophersignal/backend 2 | 3 | go 1.22.0 4 | 5 | toolchain go1.23.6 6 | 7 | require ( 8 | github.com/go-sql-driver/mysql v1.9.3 9 | github.com/gorilla/handlers v1.5.2 10 | github.com/gorilla/mux v1.8.1 11 | github.com/joho/godotenv v1.5.1 12 | github.com/stretchr/testify v1.11.1 13 | github.com/swaggo/http-swagger v1.3.4 14 | github.com/swaggo/swag v1.16.6 15 | ) 16 | 17 | require ( 18 | filippo.io/edwards25519 v1.1.0 // indirect 19 | github.com/KyleBanks/depth v1.2.1 // indirect 20 | github.com/davecgh/go-spew v1.1.1 // indirect 21 | github.com/felixge/httpsnoop v1.0.3 // indirect 22 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 23 | github.com/go-openapi/jsonreference v0.21.0 // indirect 24 | github.com/go-openapi/spec v0.21.0 // indirect 25 | github.com/go-openapi/swag v0.23.0 // indirect 26 | github.com/josharian/intern v1.0.0 // indirect 27 | github.com/mailru/easyjson v0.9.0 // indirect 28 | github.com/pmezard/go-difflib v1.0.0 // indirect 29 | github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect 30 | golang.org/x/mod v0.22.0 // indirect 31 | golang.org/x/net v0.34.0 // indirect 32 | golang.org/x/sync v0.11.0 // indirect 33 | golang.org/x/tools v0.29.0 // indirect 34 | gopkg.in/yaml.v3 v3.0.1 // indirect 35 | ) 36 | -------------------------------------------------------------------------------- /hackernews_scraper/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackernews_scraper", 3 | "version": "1.0.0", 4 | "main": "src/index.ts", 5 | "scripts": { 6 | "start": "ts-node src/index.ts", 7 | "debug": "DEBUG_MODE=true ts-node src/index.ts", 8 | "test": "NODE_ENV=test npx jest", 9 | "coverage": "jest --coverage", 10 | "build": "tsc", 11 | "lint": "eslint . --ext .ts" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "description": "", 17 | "devDependencies": { 18 | "@types/jest": "^30.0.0", 19 | "@types/mysql": "^2.15.26", 20 | "@types/node": "^24.0.1", 21 | "@types/puppeteer": "^7.0.4", 22 | "@types/sinon": "^17.0.4", 23 | "dotenv": "^17.2.2", 24 | "eslint": "^9.21.0", 25 | "jest": "^30.0.4", 26 | "nock": "^14.0.1", 27 | "sinon": "^21.0.0", 28 | "ts-jest": "^29.1.0", 29 | "ts-node": "^10.9.2", 30 | "typescript": "^5.7.2" 31 | }, 32 | "dependencies": { 33 | "@instructor-ai/instructor": "^1.5.0", 34 | "@octokit/rest": "^22.0.0", 35 | "@types/cli-progress": "^3.11.6", 36 | "axios": "^1.6.8", 37 | "cli-progress": "^3.12.0", 38 | "mysql2": "^3.12.0", 39 | "openai": "^6.1.0", 40 | "puppeteer": "^24.2.1", 41 | "puppeteer-extra": "^3.3.6", 42 | "puppeteer-extra-plugin-stealth": "^2.11.2", 43 | "zod": "^3.23.8" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /frontend/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Typography from '@mui/material/Typography'; 3 | import { FaSkullCrossbones } from 'react-icons/fa'; 4 | 5 | const Footer: React.FC = () => { 6 | return ( 7 |
8 |
9 | 21 | 28 | Made Possible 29 | 37 | by Uncle Dennis 38 | 39 | 40 |
41 |
42 | ); 43 | }; 44 | 45 | export default Footer; 46 | -------------------------------------------------------------------------------- /frontend/components/ModeButton.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useColorScheme } from '@mui/joy/styles'; 3 | import IconButton from '@mui/joy/IconButton'; 4 | import * as React from 'react'; 5 | import LightModeIcon from '@mui/icons-material/LightMode'; 6 | import DarkModeIcon from '@mui/icons-material/DarkMode'; 7 | 8 | // ModeButton component allows users to toggle between light and dark modes. 9 | export default function ModeButton() { 10 | const { mode, setMode } = useColorScheme(); 11 | const [mounted, setMounted] = React.useState(false); 12 | 13 | useEffect(() => { 14 | // Set `mounted` to `true` when the component is mounted. 15 | setMounted(true); 16 | }, []); 17 | 18 | if (!mounted) { 19 | // Render a placeholder button to avoid layout shift before mounting. 20 | return ( 21 | 27 | ); 28 | } 29 | 30 | // Render the mode toggle button based on the current mode. 31 | return ( 32 | setMode(mode === 'dark' ? 'light' : 'dark')} 37 | > 38 | {mode === 'dark' ? : } 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /hackernews_scraper/src/index.ts: -------------------------------------------------------------------------------- 1 | // Initializes clients, assembles services, and runs the article workflow. 2 | 3 | import config from './config/config'; 4 | import { createClients } from './clients/createClients'; 5 | import { createServices } from './services/createServices'; 6 | import { createWorkflow } from './workflow'; 7 | 8 | const EXIT_CODE_SUCCESS = 0; 9 | const EXIT_CODE_FAILURE = 1; 10 | 11 | const main = async (): Promise => { 12 | // Initialize workflow and set default exit code 13 | let workflow: ReturnType | null = null; 14 | let exitCode = EXIT_CODE_SUCCESS; 15 | try { 16 | // Create low-level infrastructure clients 17 | const clients = await createClients(config); 18 | 19 | // Assemble high-level services 20 | const services = createServices(clients); 21 | 22 | // Run the workflow 23 | workflow = createWorkflow(services); 24 | await workflow.run(); 25 | 26 | console.info('Workflow completed successfully'); 27 | } catch (error) { 28 | console.error('Workflow execution error:', error); 29 | exitCode = EXIT_CODE_FAILURE; 30 | } finally { 31 | // Shutdown workflow if initialized 32 | if (workflow) await workflow.shutdown(); 33 | } 34 | return exitCode; 35 | }; 36 | 37 | // Run main if this file is executed directly 38 | if (require.main === module) { 39 | main().then((exitCode) => process.exit(exitCode)); 40 | } 41 | -------------------------------------------------------------------------------- /hackernews_scraper/src/services/createServices.ts: -------------------------------------------------------------------------------- 1 | // Assembles high-level services and includes missing dependencies 2 | 3 | import { createHackerNewsScraper } from '../services/articleScraper'; 4 | import { createContentFetcher } from '../services/articleContent'; 5 | import { createArticleProcessor } from '../services/articleProcessor'; 6 | import { createArticleSummarizer } from '../services/articleSummarizer'; 7 | import { createGitHubService } from '../services/githubService'; 8 | import { SummaryResponseSchema } from '../types'; 9 | import config from '../config/config'; 10 | import { Clients } from '../clients/createClients'; 11 | 12 | export const createServices = (clients: Clients) => { 13 | const { browser, instructorClient, articleHelpers, githubClient } = clients; 14 | 15 | const githubService = createGitHubService(githubClient, config.github); 16 | const scraper = createHackerNewsScraper(browser); 17 | const contentFetcher = createContentFetcher(browser); 18 | const articleProcessor = createArticleProcessor( 19 | scraper, 20 | contentFetcher, 21 | articleHelpers 22 | ); 23 | const articleSummarizer = createArticleSummarizer( 24 | instructorClient, 25 | config.ollama, 26 | SummaryResponseSchema 27 | ); 28 | 29 | return { 30 | ...clients, 31 | scraper, 32 | articleProcessor, 33 | articleSummarizer, 34 | githubService, 35 | }; 36 | }; 37 | 38 | export type Services = ReturnType; 39 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | [Provide a concise description of the changes introduced by this pull request. Mention the motivation, context, and any relevant details.] 4 | 5 | ## Changes Made 6 | 7 | [Explain in more detail the specific changes made in this pull request. Provide an overview of the modifications, new features, bug fixes, etc.] 8 | 9 | ## Testing 10 | 11 | [Describe the testing process undertaken to validate the changes. Include information on the type of tests performed, configurations used, and any relevant results or observations.] 12 | 13 | ## Checklist 14 | 15 | - [ ] My code follows the style guidelines and best practices of this project. 16 | - [ ] I have reviewed and tested the code changes thoroughly. 17 | - [ ] I have added or updated unit tests to cover the modified code and ensure its correctness. 18 | - [ ] All existing unit tests pass with the changes. 19 | - [ ] The changes do not introduce any known security vulnerabilities. 20 | - [ ] I have considered the impact of these changes on performance, scalability, and maintainability. 21 | - [ ] The documentation has been updated to reflect the changes introduced (if applicable). 22 | 23 | ## Related Issues 24 | 25 | [Include any relevant links to issues or feature requests that are addressed or related to this pull request.] 26 | 27 | ## Additional Notes 28 | 29 | [Provide any additional information, notes, or considerations that may be useful for the reviewers or future reference.] 30 | -------------------------------------------------------------------------------- /frontend/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Layout from '../components/Layout'; 3 | import Description from '../components/Description'; 4 | import ArticleList from '../components/ArticleList'; 5 | import Typography from '@mui/joy/Typography'; 6 | import Footer from '../components/Footer'; 7 | 8 | // Index component renders the main page layout, including description and article list. 9 | const Index: React.FC = () => { 10 | return ( 11 | 12 | {/* Wrap everything in a container that ensures content pushes the footer down */} 13 |
20 | {/* Main content fills available space */} 21 |
22 | 23 | 24 | {/* Heading for the latest articles section. */} 25 | 30 | Latest Articles 31 | 32 | 33 | {/* List of the latest articles. */} 34 |
35 | 36 |
37 |
38 | 39 | {/* Footer stays at the bottom */} 40 |
41 |
42 |
43 | ); 44 | }; 45 | 46 | export default Index; 47 | -------------------------------------------------------------------------------- /backend/internal/api/router/router_test.go: -------------------------------------------------------------------------------- 1 | // Package router contains the unit tests for verifying the router configuration and route handling in the GopherSignal application. 2 | package router 3 | 4 | import ( 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/k-zehnder/gophersignal/backend/config" 10 | "github.com/k-zehnder/gophersignal/backend/internal/api/handlers" 11 | "github.com/k-zehnder/gophersignal/backend/internal/models" 12 | "github.com/k-zehnder/gophersignal/backend/internal/store" 13 | ) 14 | 15 | // TestRouter_ArticlesRoute tests the articles route in the router. 16 | func TestRouter_ArticlesRoute(t *testing.T) { 17 | // Set up a mock store with no articles to simulate database interaction. 18 | mockStore := store.NewMockStore([]*models.Article{}, nil, nil) 19 | 20 | // Initialize the ArticlesHandler with the mock store and configuration. 21 | cfg := config.NewConfig() 22 | articlesHandler := handlers.NewArticlesHandler(mockStore, cfg) 23 | 24 | // Set up the router. 25 | router := SetupRouter(articlesHandler) 26 | 27 | // Create a new HTTP request to test the articles route. 28 | req := httptest.NewRequest("GET", "/api/v1/articles", nil) 29 | rr := httptest.NewRecorder() 30 | 31 | // Serve the request using the router and record the response. 32 | router.ServeHTTP(rr, req) 33 | 34 | // Check if the response status code is as expected (200 OK). 35 | if status := rr.Code; status != http.StatusOK { 36 | t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /rss/src/errors/errors.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | http::StatusCode, 3 | response::{IntoResponse, Response}, 4 | }; 5 | use std::net::AddrParseError; 6 | use thiserror::Error; 7 | 8 | #[derive(Error, Debug)] 9 | pub enum AppError { 10 | #[error("Database error: {0}")] 11 | Database(#[from] sqlx::Error), 12 | 13 | #[error("Article fetch error: {0}")] 14 | ArticleFetch(String), 15 | 16 | #[error("RSS build error: {0}")] 17 | RssBuild(String), 18 | 19 | #[error("IO error: {0}")] 20 | Io(#[from] std::io::Error), 21 | 22 | #[error("Address parse error: {0}")] 23 | AddrParse(#[from] AddrParseError), 24 | 25 | #[error("Service error: {0}")] 26 | BoxedError(#[from] Box), 27 | } 28 | 29 | impl From for AppError { 30 | fn from(err: axum::http::Error) -> Self { 31 | AppError::BoxedError(Box::new(err)) 32 | } 33 | } 34 | 35 | // Update IntoResponse implementation 36 | impl IntoResponse for AppError { 37 | fn into_response(self) -> Response { 38 | let status = match &self { 39 | AppError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, 40 | AppError::ArticleFetch(_) => StatusCode::BAD_GATEWAY, 41 | AppError::RssBuild(_) => StatusCode::INTERNAL_SERVER_ERROR, 42 | AppError::Io(_) => StatusCode::INTERNAL_SERVER_ERROR, 43 | AppError::AddrParse(_) => StatusCode::BAD_REQUEST, 44 | AppError::BoxedError(_) => StatusCode::INTERNAL_SERVER_ERROR, 45 | }; 46 | 47 | (status, self.to_string()).into_response() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /hackernews_scraper/src/services/githubService.ts: -------------------------------------------------------------------------------- 1 | // GitHubService provides methods to fetch Git-related info. 2 | 3 | import { execSync } from 'child_process'; 4 | import { Octokit } from '@octokit/rest'; 5 | import { GitHubConfig } from '../types/config'; 6 | import { GitHubService } from '../types'; 7 | 8 | export function createGitHubService( 9 | client: Octokit, 10 | cfg: GitHubConfig 11 | ): GitHubService { 12 | const getCommitHash = async (): Promise => { 13 | let sha: string | undefined; 14 | 15 | // Env override 16 | if (process.env.COMMIT_HASH) { 17 | sha = process.env.COMMIT_HASH; 18 | } 19 | 20 | // GitHub API 21 | if (!sha) { 22 | try { 23 | const { data } = await client.rest.repos.getCommit({ 24 | owner: cfg.owner, 25 | repo: cfg.repo, 26 | ref: cfg.branch, 27 | }); 28 | sha = data.sha.slice(0, 7); 29 | } catch (err) { 30 | console.warn('GitHub API failed:', (err as Error).message); 31 | } 32 | } 33 | 34 | // Local git 35 | if (!sha) { 36 | console.log('Trying local git…'); 37 | try { 38 | sha = execSync('git rev-parse --short HEAD', { 39 | encoding: 'utf-8', 40 | }).trim(); 41 | } catch (err) { 42 | console.error('Local git failed:', (err as Error).message); 43 | } 44 | } 45 | 46 | // Final fallback 47 | if (!sha) { 48 | sha = 'unknown'; 49 | console.log('Falling back to unknown SHA'); 50 | } 51 | 52 | return sha; 53 | }; 54 | 55 | // Expose the service API 56 | return { 57 | getCommitHash, 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | frontend: 3 | image: kjzehnder3/gophersignal-frontend:latest 4 | ports: 5 | - '3000:3000' 6 | depends_on: 7 | - backend 8 | networks: 9 | - app-network 10 | env_file: 11 | - .env 12 | restart: unless-stopped 13 | 14 | backend: 15 | image: kjzehnder3/gophersignal-backend:latest 16 | ports: 17 | - '8080:8080' 18 | networks: 19 | - app-network 20 | env_file: 21 | - .env 22 | restart: unless-stopped 23 | extra_hosts: 24 | - 'host.docker.internal:host-gateway' 25 | 26 | hackernews_scraper: 27 | image: kjzehnder3/gophersignal-hackernews_scraper:latest 28 | networks: 29 | - app-network 30 | env_file: 31 | - .env 32 | restart: unless-stopped 33 | extra_hosts: 34 | - 'host.docker.internal:host-gateway' 35 | 36 | rss: 37 | image: kjzehnder3/gophersignal-rss:latest 38 | ports: 39 | - '9090:9090' 40 | networks: 41 | - app-network 42 | env_file: 43 | - .env 44 | depends_on: 45 | - backend 46 | restart: unless-stopped 47 | extra_hosts: 48 | - 'host.docker.internal:host-gateway' 49 | 50 | nginx: 51 | image: nginx:latest 52 | ports: 53 | - '80:80' 54 | - '443:443' 55 | networks: 56 | - app-network 57 | volumes: 58 | - /etc/letsencrypt:/etc/letsencrypt:ro 59 | - ./nginx/production.conf:/etc/nginx/nginx.conf 60 | - ./frontend/out:/usr/share/nginx/html 61 | depends_on: 62 | - backend 63 | - rss 64 | restart: unless-stopped 65 | 66 | networks: 67 | app-network: 68 | driver: bridge 69 | 70 | volumes: 71 | ollama: 72 | driver: local 73 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION ?= latest 2 | DOCKERHUB_REPO := kjzehnder3/gophersignal 3 | FRONTEND_IMAGE_TAG := $(DOCKERHUB_REPO)-frontend:$(VERSION) 4 | BACKEND_IMAGE_TAG := $(DOCKERHUB_REPO)-backend:$(VERSION) 5 | HACKERNEWS_SCRAPER_IMAGE_TAG := $(DOCKERHUB_REPO)-hackernews_scraper:$(VERSION) 6 | RSS_IMAGE_TAG := $(DOCKERHUB_REPO)-rss:$(VERSION) 7 | 8 | export FRONTEND_IMAGE_TAG BACKEND_IMAGE_TAG HACKERNEWS_SCRAPER_IMAGE_TAG RSS_IMAGE_TAG 9 | 10 | .PHONY: all 11 | all: build test push 12 | 13 | .PHONY: build 14 | build: 15 | @echo "Building all components..." 16 | $(MAKE) -C frontend build 17 | $(MAKE) -C backend build 18 | $(MAKE) -C hackernews_scraper build 19 | $(MAKE) -C rss build 20 | 21 | .PHONY: test 22 | test: 23 | @echo "Running tests for all components..." 24 | $(MAKE) -C frontend test 25 | $(MAKE) -C backend test 26 | $(MAKE) -C hackernews_scraper test 27 | $(MAKE) -C rss test 28 | 29 | .PHONY: push 30 | push: 31 | @echo "Pushing all images..." 32 | $(MAKE) -C frontend push 33 | $(MAKE) -C backend push 34 | $(MAKE) -C hackernews_scraper push 35 | $(MAKE) -C rss push 36 | 37 | .PHONY: deploy 38 | deploy: 39 | @echo "Deploying application..." 40 | docker compose down 41 | docker pull $(FRONTEND_IMAGE_TAG) 42 | docker pull $(BACKEND_IMAGE_TAG) 43 | docker pull $(HACKERNEWS_SCRAPER_IMAGE_TAG) 44 | docker pull $(RSS_IMAGE_TAG) 45 | docker compose up -d 46 | docker compose restart nginx 47 | @echo "Application deployed successfully." 48 | 49 | .PHONY: dev 50 | dev: 51 | @echo "Starting development environment..." 52 | docker compose -f docker-compose-dev.yml up -d --build 53 | 54 | .PHONY: scrape 55 | scrape: 56 | @echo "Running HackerNews Scraper inside container..." 57 | docker compose run --rm hackernews_scraper npm run start 58 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "NEXT_PUBLIC_ENV=development next dev", 5 | "build": "next build", 6 | "start": "next start", 7 | "export": "next export", 8 | "test": "npx jest", 9 | "test:watch": "jest --watch" 10 | }, 11 | "dependencies": { 12 | "@babel/traverse": "^7.23.9", 13 | "@emotion/react": "^11.10.5", 14 | "@emotion/server": "^11.11.0", 15 | "@emotion/styled": "^11.11.0", 16 | "@fortawesome/free-solid-svg-icons": "^6.7.2", 17 | "@fortawesome/react-fontawesome": "^0.2.2", 18 | "@mui/icons-material": "^5.15.11", 19 | "@mui/joy": "^5.0.0-alpha.65", 20 | "date-fns": "^3.3.1", 21 | "gray-matter": "^4.0.3", 22 | "ip": "^2.0.1", 23 | "next": "^14.2.32", 24 | "npm": "^10.6.0", 25 | "postcss": "^8.4.35", 26 | "react": "18.2.0", 27 | "react-dom": "18.2.0", 28 | "react-icons": "^5.4.0", 29 | "remark": "^15.0.1", 30 | "remark-html": "^16.0.1", 31 | "semver": "^7.6.0", 32 | "zod": "^3.24.1" 33 | }, 34 | "devDependencies": { 35 | "@babel/core": "^7.24.4", 36 | "@babel/preset-env": "^7.24.4", 37 | "@babel/preset-react": "^7.24.1", 38 | "@babel/preset-typescript": "^7.24.1", 39 | "@playwright/test": "^1.42.0", 40 | "@testing-library/jest-dom": "^6.4.2", 41 | "@testing-library/react": "^15.0.4", 42 | "@types/node": "^20.11.20", 43 | "@types/react": "^18.2.60", 44 | "babel-jest": "^29.7.0", 45 | "dotenv": "^16.4.7", 46 | "jest": "^29.7.0", 47 | "jest-environment-jsdom": "^29.7.0", 48 | "next-router-mock": "^0.9.13", 49 | "prettier": "^3.2.5", 50 | "ts-jest": "^29.1.2", 51 | "ts-node": "^10.9.2", 52 | "typescript": "^5.4.5" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /frontend/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Head from 'next/head'; 3 | import { CssVarsProvider } from '@mui/joy/styles'; 4 | import GlobalStyles from '@mui/joy/GlobalStyles'; 5 | import theme from '../lib/theme'; 6 | import CssBaseline from '@mui/joy/CssBaseline'; 7 | 8 | const MyApp: React.FC<{ Component: React.ElementType; pageProps: any }> = ({ 9 | Component, 10 | pageProps, 11 | }) => { 12 | return ( 13 | <> 14 | 15 | {/* Set viewport meta tag for responsive design */} 16 | 17 | 18 | 24 | 25 | 47 | {/* Render the component passed through props with its pageProps. */} 48 | 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default MyApp; 55 | -------------------------------------------------------------------------------- /backend/internal/api/router/router.go: -------------------------------------------------------------------------------- 1 | // Package router configures the HTTP router for the GopherSignal API, 2 | // including CORS settings and Swagger documentation. 3 | package router 4 | 5 | import ( 6 | "net/http" 7 | 8 | gorillaHandlers "github.com/gorilla/handlers" 9 | "github.com/gorilla/mux" 10 | "github.com/k-zehnder/gophersignal/backend/config" 11 | "github.com/k-zehnder/gophersignal/backend/internal/api/handlers" 12 | "github.com/k-zehnder/gophersignal/backend/internal/store" 13 | httpSwagger "github.com/swaggo/http-swagger" 14 | ) 15 | 16 | // NewRouter creates an http.Handler with configured routes and handlers. 17 | func NewRouter(store store.Store, cfg *config.AppConfig) http.Handler { 18 | articlesHandler := handlers.NewArticlesHandler(store, cfg) 19 | return SetupRouter(articlesHandler) 20 | } 21 | 22 | // SetupRouter initializes and returns a configured mux.Router. 23 | func SetupRouter(articlesHandler *handlers.ArticlesHandler) *mux.Router { 24 | r := mux.NewRouter() 25 | 26 | // Setup CORS for various development and production environments. 27 | cors := gorillaHandlers.CORS( 28 | gorillaHandlers.AllowedOrigins([]string{ 29 | "http://localhost:3000", // Local frontend dev server. 30 | "http://localhost:8080", // Local dev server. 31 | "https://gophersignal.com", // Production frontend. 32 | "https://www.gophersignal.com", // Production frontend with www. 33 | }), 34 | gorillaHandlers.AllowedMethods([]string{"GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"}), 35 | gorillaHandlers.AllowedHeaders([]string{"Content-Type", "Authorization"}), 36 | gorillaHandlers.AllowCredentials(), 37 | ) 38 | r.Use(cors) 39 | 40 | // Setup API v1 routes. 41 | apiRouter := r.PathPrefix("/api/v1").Subrouter() 42 | apiRouter.Handle("/articles", articlesHandler).Methods("GET") 43 | 44 | // Endpoint for Swagger documentation at '/swagger'. 45 | r.PathPrefix("/swagger").Handler(httpSwagger.WrapHandler) 46 | 47 | return r 48 | } 49 | -------------------------------------------------------------------------------- /hackernews_scraper/src/config/config.ts: -------------------------------------------------------------------------------- 1 | // Loads environment variables and provides configuration settings. 2 | 3 | import dotenv from 'dotenv'; 4 | import { z } from 'zod'; 5 | import { Config } from '../types/index'; 6 | 7 | dotenv.config(); 8 | 9 | const envSchema = z.object({ 10 | MYSQL_HOST: z.string().default('localhost'), 11 | MYSQL_PORT: z.string().default('3306'), 12 | MYSQL_USER: z.string().default('user'), 13 | MYSQL_PASSWORD: z.string().default(''), 14 | MYSQL_DATABASE: z.string().default('database_name'), 15 | 16 | OLLAMA_BASE_URL: z.string().default('http://localhost:11434/api/generate'), 17 | OLLAMA_MODEL: z.string().default('qwen3:8b'), 18 | OLLAMA_API_KEY: z.string().optional(), 19 | OLLAMA_CONTEXT_LENGTH: z.string().optional(), // Add env var for context length 20 | MAX_CONTENT_LENGTH: z.string().default('2000'), 21 | MAX_SUMMARY_LENGTH: z.string().default('500'), 22 | 23 | GH_TOKEN: z.string().optional(), 24 | GITHUB_OWNER: z.string().default('k-zehnder'), 25 | GITHUB_REPO: z.string().default('gophersignal'), 26 | GITHUB_BRANCH: z.string().default('main'), 27 | }); 28 | 29 | const env = envSchema.parse(process.env); 30 | 31 | const config: Config = { 32 | mysql: { 33 | host: env.MYSQL_HOST, 34 | port: parseInt(env.MYSQL_PORT, 10), 35 | user: env.MYSQL_USER, 36 | password: env.MYSQL_PASSWORD, 37 | database: env.MYSQL_DATABASE, 38 | }, 39 | ollama: { 40 | baseUrl: env.OLLAMA_BASE_URL, 41 | model: env.OLLAMA_MODEL, 42 | apiKey: env.OLLAMA_API_KEY, 43 | maxContentLength: parseInt(env.MAX_CONTENT_LENGTH, 10), 44 | maxSummaryLength: parseInt(env.MAX_SUMMARY_LENGTH, 10), 45 | // Parse OLLAMA_CONTEXT_LENGTH, default to 8192 if not set or invalid 46 | numCtx: parseInt(env.OLLAMA_CONTEXT_LENGTH || '8192', 10) || 8192, 47 | }, 48 | github: { 49 | token: env.GH_TOKEN, 50 | owner: env.GITHUB_OWNER, 51 | repo: env.GITHUB_REPO, 52 | branch: env.GITHUB_BRANCH, 53 | }, 54 | }; 55 | 56 | export default config; 57 | -------------------------------------------------------------------------------- /rss/src/services/articles.rs: -------------------------------------------------------------------------------- 1 | use crate::config::config::AppConfig; 2 | use crate::models::article::{ApiResponse, Article}; 3 | use crate::routes::rss::RssQuery; 4 | use async_trait::async_trait; 5 | use reqwest::Client; 6 | 7 | #[async_trait] 8 | pub trait ArticlesClient: Send + Sync { 9 | async fn fetch_articles( 10 | &self, 11 | query: &RssQuery, 12 | config: &AppConfig, 13 | ) -> Result, Box>; 14 | } 15 | 16 | #[derive(Clone)] 17 | pub struct HttpArticlesClient; 18 | 19 | #[async_trait] 20 | impl ArticlesClient for HttpArticlesClient { 21 | async fn fetch_articles( 22 | &self, 23 | query: &RssQuery, 24 | config: &AppConfig, 25 | ) -> Result, Box> { 26 | let client = Client::new(); 27 | let backend_url = config.api_url.clone(); 28 | let mut request = client.get(&backend_url); 29 | 30 | // Build query parameters based on RssQuery. 31 | let mut params = Vec::new(); 32 | if let Some(flagged) = query.flagged { 33 | params.push(("flagged", flagged.to_string())); 34 | } 35 | if let Some(dead) = query.dead { 36 | params.push(("dead", dead.to_string())); 37 | } 38 | if let Some(dupe) = query.dupe { 39 | params.push(("dupe", dupe.to_string())); 40 | } 41 | if let Some(min_upvotes) = query.min_upvotes { 42 | params.push(("min_upvotes", min_upvotes.to_string())); 43 | } 44 | if let Some(min_comments) = query.min_comments { 45 | params.push(("min_comments", min_comments.to_string())); 46 | } 47 | if !params.is_empty() { 48 | request = request.query(¶ms); 49 | } 50 | 51 | let response = request.send().await?; 52 | let api_response: ApiResponse = response.json().await?; 53 | Ok(api_response.articles.unwrap_or_default()) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /frontend/components/__tests__/Description.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | import Description from '../../components/Description'; 5 | 6 | describe('Description Component', () => { 7 | it('renders the welcome message correctly', () => { 8 | render(); 9 | // Check if the heading contains the expected text 10 | expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent( 11 | 'Welcome to Gopher Signal', 12 | ); 13 | }); 14 | 15 | it('renders the explanation text correctly', () => { 16 | render(); 17 | // Use a function matcher to find the text across multiple elements 18 | const explanationText = screen.getByText((_, node: any) => { 19 | // Helper function to match the node's text content accurately 20 | const hasText = (node: any) => 21 | node.textContent === 22 | 'Gopher Signal uses smart technology to quickly summarize important points from Hacker News articles, giving you brief and useful updates.'; 23 | const nodeHasText = hasText(node); 24 | // Ensure none of the child elements contain the same text 25 | const childrenDontHaveText = Array.from(node.children).every( 26 | (child) => !hasText(child), 27 | ); 28 | 29 | return nodeHasText && childrenDontHaveText; 30 | }); 31 | expect(explanationText).toBeInTheDocument(); 32 | }); 33 | 34 | it('contains a link to Hacker News with the correct attributes', () => { 35 | render(); 36 | // Verify that the link to Hacker News is correctly set up 37 | const hackerNewsLink = screen.getByRole('link', { name: 'Hacker News' }); 38 | expect(hackerNewsLink).toHaveAttribute( 39 | 'href', 40 | 'https://news.ycombinator.com', 41 | ); 42 | expect(hackerNewsLink).toHaveAttribute('target', '_blank'); 43 | expect(hackerNewsLink).toHaveAttribute('rel', 'noopener noreferrer'); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /frontend/types/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | // Define the Zod schema for a single article 4 | export const ArticleSchema = z.object({ 5 | id: z.union([z.string(), z.number()]).transform((value) => value.toString()), 6 | title: z.string(), 7 | source: z.string().optional(), 8 | created_at: z.string().optional(), 9 | updated_at: z.string().optional(), 10 | summary: z 11 | .union([ 12 | z.object({ 13 | String: z.string(), 14 | Valid: z.boolean(), 15 | }), 16 | z.string(), 17 | ]) 18 | .transform((val) => { 19 | if (typeof val === 'string') { 20 | return val; 21 | } 22 | return val.Valid ? val.String : ''; 23 | }), 24 | link: z.string().url(), 25 | upvotes: z 26 | .union([ 27 | z.object({ 28 | Int64: z.number(), 29 | Valid: z.boolean(), 30 | }), 31 | z.number(), // To handle cases where upvotes is a plain number 32 | ]) 33 | .transform((val) => 34 | typeof val === 'number' ? val : val.Valid ? val.Int64 : 0 35 | ), 36 | comment_count: z 37 | .union([ 38 | z.object({ 39 | Int64: z.number(), 40 | Valid: z.boolean(), 41 | }), 42 | z.number(), // To handle cases where comment_count is a plain number 43 | ]) 44 | .transform((val) => 45 | typeof val === 'number' ? val : val.Valid ? val.Int64 : 0 46 | ), 47 | comment_link: z 48 | .union([ 49 | z.object({ 50 | String: z.string(), 51 | Valid: z.boolean(), 52 | }), 53 | z.string(), // To handle cases where comment_link is a plain string 54 | ]) 55 | .transform((val) => 56 | typeof val === 'string' ? val : val.Valid ? val.String : '' 57 | ), 58 | }); 59 | 60 | // Define the Zod schema for the API response 61 | export const ArticlesResponseSchema = z.object({ 62 | code: z.number(), 63 | status: z.string(), 64 | articles: z.array(ArticleSchema), 65 | }); 66 | 67 | // Define TypeScript types from Zod schemas 68 | export type Article = z.infer; 69 | export type ArticlesResponse = z.infer; 70 | -------------------------------------------------------------------------------- /frontend/hooks/useArticles.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { processSummary, formatDate } from '../lib/stringUtils'; 3 | import { Article, ArticlesResponseSchema } from '../types'; 4 | 5 | // Custom React hook to fetch and manage a list of articles 6 | const useArticles = () => { 7 | const [articles, setArticles] = useState([]); 8 | 9 | useEffect(() => { 10 | // Determine the API URL based on the environment 11 | const apiUrl = 12 | process.env.NEXT_PUBLIC_ENV === 'development' 13 | ? 'http://localhost:8080/api/v1/articles' 14 | : 'https://gophersignal.com/api/v1/articles'; 15 | 16 | const fetchArticles = async () => { 17 | try { 18 | const response = await fetch(apiUrl); 19 | 20 | if (!response.ok) { 21 | throw new Error('Network response was not ok'); 22 | } 23 | 24 | // Parse the API response 25 | const jsonResponse = await response.json(); 26 | const apiResponse = ArticlesResponseSchema.parse(jsonResponse); 27 | 28 | // Transform articles data into the desired format 29 | const articlesData: Article[] = apiResponse.articles.map( 30 | (item: Article) => ({ 31 | id: item.id, 32 | title: item.title, 33 | source: item.source, 34 | created_at: item.created_at 35 | ? formatDate(item.created_at) 36 | : undefined, 37 | updated_at: item.updated_at 38 | ? formatDate(item.updated_at) 39 | : undefined, 40 | summary: processSummary( 41 | item.summary ? { String: item.summary, Valid: true } : null 42 | ), 43 | link: item.link, 44 | upvotes: item.upvotes, 45 | comment_count: item.comment_count, 46 | comment_link: item.comment_link, 47 | }) 48 | ); 49 | 50 | setArticles(articlesData); 51 | } catch (error) { 52 | console.error('Error fetching articles:', error); 53 | } 54 | }; 55 | 56 | fetchArticles(); 57 | }, []); 58 | 59 | return articles; 60 | }; 61 | 62 | export default useArticles; 63 | -------------------------------------------------------------------------------- /nginx/development.conf: -------------------------------------------------------------------------------- 1 | events {} 2 | 3 | http { 4 | upstream frontend { 5 | server frontend:3000; 6 | } 7 | 8 | upstream backend { 9 | server backend:8080; 10 | } 11 | 12 | upstream ollama { 13 | server ollama:11434; 14 | } 15 | 16 | upstream rss { 17 | server rss:9090; 18 | } 19 | 20 | server { 21 | listen 80; 22 | 23 | # Proxy for the frontend 24 | location / { 25 | proxy_pass http://frontend; 26 | proxy_set_header Host $host; 27 | proxy_set_header X-Real-IP $remote_addr; 28 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 29 | proxy_set_header X-Forwarded-Proto $scheme; 30 | } 31 | 32 | # Proxy for the backend API 33 | location /api { 34 | proxy_pass http://backend; 35 | proxy_set_header Host $host; 36 | proxy_set_header X-Real-IP $remote_addr; 37 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 38 | proxy_set_header X-Forwarded-Proto $scheme; 39 | } 40 | 41 | # Proxy for Swagger 42 | location /swagger { 43 | proxy_pass http://backend/swagger; 44 | proxy_http_version 1.1; 45 | proxy_set_header Host $host; 46 | proxy_set_header X-Real-IP $remote_addr; 47 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 48 | proxy_set_header X-Forwarded-Proto $scheme; 49 | } 50 | 51 | # Proxy for Ollama 52 | location /ollama { 53 | proxy_pass http://ollama; 54 | proxy_set_header Host $host; 55 | proxy_set_header X-Real-IP $remote_addr; 56 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 57 | proxy_set_header X-Forwarded-Proto $scheme; 58 | } 59 | 60 | # Proxy for RSS feed 61 | location /rss { 62 | proxy_pass http://rss; 63 | proxy_set_header Host $host; 64 | proxy_set_header X-Real-IP $remote_addr; 65 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 66 | proxy_set_header X-Forwarded-Proto $scheme; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /frontend/components/__tests__/NavBar.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom'; 3 | import { render, screen } from '@testing-library/react'; 4 | import NavBar from '../../components/NavBar'; 5 | import { CssVarsProvider } from '@mui/joy/styles'; 6 | import { siteMetaData } from '../../lib/siteMetaData'; 7 | 8 | // Mocking window.matchMedia to ensure it can be used in tests as it's not implemented in jsdom 9 | window.matchMedia = jest.fn().mockImplementation((query) => ({ 10 | matches: false, // Default matches to false unless specified otherwise 11 | media: query, 12 | onchange: null, 13 | addListener: jest.fn(), // Mock implementation for addListener 14 | removeListener: jest.fn(), // Mock implementation for removeListener 15 | })); 16 | 17 | describe('NavBar Component', () => { 18 | it('renders the navigation links and the site title', () => { 19 | render( 20 | 21 | 22 | , 23 | ); 24 | 25 | // Check for the site title as a heading to verify it is rendered correctly 26 | const siteTitle = screen.getByRole('heading', { name: siteMetaData.title }); 27 | expect(siteTitle).toBeInTheDocument(); 28 | 29 | // Check that navigation links are rendered and properly labeled 30 | const homeLink = screen.getByRole('link', { name: 'Home' }); 31 | expect(homeLink).toBeInTheDocument(); 32 | expect(homeLink).toHaveAttribute('href', '/'); 33 | 34 | const aboutLink = screen.getByRole('link', { name: 'About' }); 35 | expect(aboutLink).toBeInTheDocument(); 36 | expect(aboutLink).toHaveAttribute('href', '/about'); 37 | 38 | // Check if the API link is correct based on environment settings 39 | const apiUrl = 40 | process.env.NEXT_PUBLIC_ENV === 'development' 41 | ? 'http://localhost:8080/swagger/index.html#/' 42 | : 'https://gophersignal.com/swagger/index.html#/'; 43 | const apiLink = screen.getByRole('link', { name: 'API' }); 44 | expect(apiLink).toBeInTheDocument(); 45 | expect(apiLink).toHaveAttribute('href', apiUrl); 46 | 47 | // Locate the ModeButton by its aria-label to ensure it's accessible and functioning 48 | const modeButton = screen.getByLabelText('Toggle light and dark mode'); 49 | expect(modeButton).toBeInTheDocument(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /frontend/pages/about.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Layout from '../components/Layout'; 3 | import Typography from '@mui/joy/Typography'; 4 | import Avatar from '@mui/joy/Avatar'; 5 | import { siteMetaData } from '../lib/siteMetaData'; 6 | 7 | export default function About() { 8 | return ( 9 | 10 | {/* Display the heading "About". */} 11 | 15 | About 16 | 17 | 18 | {/* Display the Gopher Signal logo as an Avatar. */} 19 | 27 | 28 | {/* Provide information about Gopher Signal. */} 29 | 30 | Gopher Signal uses smart technology to quickly summarize important 31 | points from{' '} 32 | 41 | (e.currentTarget.style.textDecoration = 'underline') 42 | } 43 | onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')} 44 | > 45 | Hacker News 46 | {' '} 47 | articles, giving you brief and useful updates. 48 | 49 | 50 | {/* Provide GitHub link. */} 51 | 52 | Check out the project on{' '} 53 | 62 | (e.currentTarget.style.textDecoration = 'underline') 63 | } 64 | onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')} 65 | > 66 | GitHub 67 | 68 | . 69 | 70 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /nginx/production.conf: -------------------------------------------------------------------------------- 1 | events { 2 | worker_connections 1024; 3 | } 4 | 5 | http { 6 | upstream backend { 7 | server backend:8080; 8 | } 9 | 10 | upstream rss { 11 | server rss:9090; 12 | } 13 | 14 | server { 15 | listen 80; 16 | listen [::]:80; 17 | listen 443 ssl http2; 18 | listen [::]:443 ssl http2 ipv6only=on; 19 | 20 | server_name gophersignal.com www.gophersignal.com; 21 | 22 | # Redirect HTTP to HTTPS 23 | if ($scheme != "https") { 24 | return 301 https://$host$request_uri; 25 | } 26 | 27 | ssl_certificate /etc/letsencrypt/live/gophersignal.com/fullchain.pem; 28 | ssl_certificate_key /etc/letsencrypt/live/gophersignal.com/privkey.pem; 29 | 30 | # Serve static content for the root (index) 31 | location / { 32 | root /usr/share/nginx/html; 33 | index index.html index.htm; 34 | try_files $uri $uri/ $uri.html /index.html; 35 | } 36 | 37 | # Proxy for the backend API 38 | location /api { 39 | proxy_pass http://backend; # Proxy to the upstream backend 40 | proxy_set_header Upgrade $http_upgrade; 41 | proxy_set_header Connection 'upgrade'; 42 | proxy_set_header Host $host; 43 | proxy_cache_bypass $http_upgrade; 44 | } 45 | 46 | # Proxy for Swagger UI 47 | location /swagger { 48 | proxy_pass http://backend/swagger; # Proxy to the upstream backend 49 | proxy_set_header Host $host; 50 | proxy_set_header X-Real-IP $remote_addr; 51 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 52 | proxy_set_header X-Forwarded-Proto $scheme; 53 | } 54 | 55 | # Proxy for the RSS feed 56 | location /rss { 57 | proxy_pass http://rss; # Proxy to the RSS service 58 | proxy_set_header Host $host; 59 | proxy_set_header X-Real-IP $remote_addr; 60 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 61 | proxy_set_header X-Forwarded-Proto $scheme; 62 | } 63 | } 64 | 65 | # Additional server block for redirecting all HTTP traffic to HTTPS 66 | server { 67 | listen 80; 68 | listen [::]:80; 69 | server_name gophersignal.com www.gophersignal.com; 70 | 71 | return 301 https://$host$request_uri; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /backend/internal/api/server/server_test.go: -------------------------------------------------------------------------------- 1 | // Package server includes the unit test for the NewServer function of the GopherSignal application. 2 | package server 3 | 4 | import ( 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/k-zehnder/gophersignal/backend/config" 11 | "github.com/k-zehnder/gophersignal/backend/internal/api/router" 12 | "github.com/k-zehnder/gophersignal/backend/internal/models" 13 | "github.com/k-zehnder/gophersignal/backend/internal/store" 14 | ) 15 | 16 | // TestNewServer validates the server's behavior by simulating HTTP requests with mock data 17 | // and verifying the responses, thus confirming the accuracy and reliability of the API's outputs. 18 | func TestNewServer(t *testing.T) { 19 | // Initialize test articles 20 | mockArticles := []*models.Article{ 21 | { 22 | ID: 1, 23 | Title: "Test Article 1", 24 | Content: "Content of Test Article 1", 25 | }, 26 | { 27 | ID: 2, 28 | Title: "Test Article 2", 29 | Content: "Content of Test Article 2", 30 | }, 31 | } 32 | 33 | // Create a MockStore with the mock data 34 | mockStore := store.NewMockStore(mockArticles, nil, nil) 35 | 36 | // Initialize configuration and then the router with the mock store 37 | cfg := config.NewConfig() 38 | handler := router.NewRouter(mockStore, cfg) 39 | 40 | // Adjust the request URL to include the API prefix 41 | req, err := http.NewRequest("GET", "/api/v1/articles", nil) 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | rr := httptest.NewRecorder() 46 | 47 | // Serve the HTTP request 48 | handler.ServeHTTP(rr, req) 49 | 50 | // Check for the expected status code 51 | if status := rr.Code; status != http.StatusOK { 52 | t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) 53 | } 54 | 55 | // Parse the response body to check if it returns the correct articles 56 | var articlesResponse models.ArticlesResponse 57 | err = json.Unmarshal(rr.Body.Bytes(), &articlesResponse) 58 | if err != nil { 59 | t.Fatalf("Failed to unmarshal response: %v", err) 60 | } 61 | 62 | // Assert the content of the response 63 | if len(articlesResponse.Articles) != len(mockArticles) { 64 | t.Errorf("Expected %d articles, got %d", len(mockArticles), len(articlesResponse.Articles)) 65 | } 66 | for i, article := range articlesResponse.Articles { 67 | if article.ID != mockArticles[i].ID || article.Title != mockArticles[i].Title { 68 | t.Errorf("Expected article %v, got %v", mockArticles[i], article) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /frontend/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Document, { 3 | Html, 4 | Head, 5 | Main, 6 | NextScript, 7 | DocumentContext, 8 | } from 'next/document'; 9 | import { ReactElement } from 'react'; 10 | 11 | // Extends Next.js's default Document to customize the HTML document structure. 12 | export default class MyDocument extends Document { 13 | static async getInitialProps(ctx: DocumentContext): Promise { 14 | const initialProps = await Document.getInitialProps(ctx); 15 | return { ...initialProps }; 16 | } 17 | 18 | render(): ReactElement { 19 | return ( 20 | 21 | 22 | {/* Standard Favicon */} 23 | 24 | 25 | {/* PNG Favicons */} 26 | 32 | 38 | 44 | 50 | 51 | {/* Apple Touch Icon for iOS */} 52 | 57 | 58 | {/* Web App Manifest */} 59 | 60 | 61 | {/* Google Analytics Script */} 62 | 66 |