Merge branch 'main' into dev
@@ -1,91 +1,71 @@
|
|||||||
name: CI/CD
|
name: CI/CD
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
- main
|
- main
|
||||||
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
JAVA_VERSION: "21"
|
JAVA_VERSION: "21"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
backend-unit-tests:
|
backend-unit-tests:
|
||||||
name: Backend Unit Tests
|
name: Backend Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up JDK ${{ env.JAVA_VERSION }}
|
- name: Set up JDK ${{ env.JAVA_VERSION }}
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: ${{ env.JAVA_VERSION }}
|
java-version: ${{ env.JAVA_VERSION }}
|
||||||
|
|
||||||
- name: Make Gradle wrapper executable
|
- name: Make Gradle wrapper executable
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: ./gradlew test
|
run: ./gradlew test
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy
|
name: Deploy
|
||||||
needs: backend-unit-tests
|
needs: backend-unit-tests
|
||||||
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up JDK ${{ env.JAVA_VERSION }}
|
- name: Set up JDK ${{ env.JAVA_VERSION }}
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: ${{ env.JAVA_VERSION }}
|
java-version: ${{ env.JAVA_VERSION }}
|
||||||
|
|
||||||
- name: Build boot jar
|
- name: Build boot jar
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
run: |
|
run: |
|
||||||
chmod +x gradlew
|
chmod +x gradlew
|
||||||
./gradlew bootJar
|
./gradlew bootJar
|
||||||
|
|
||||||
- name: Stage jar for Docker
|
- name: Stage jar for Docker
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
run: |
|
run: |
|
||||||
BOOT_JAR=$(find build/libs -name "*.jar" ! -name "*-plain.jar" | head -n 1)
|
BOOT_JAR=$(find build/libs -name "*.jar" ! -name "*-plain.jar" | head -n 1)
|
||||||
cp "$BOOT_JAR" app.jar
|
cp "$BOOT_JAR" app.jar
|
||||||
|
|
||||||
- name: Login to Docker registry
|
- name: Login to Docker registry
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||||
docker login git.${{ secrets.DOMAIN }} \
|
docker login git.${{ secrets.DOMAIN }} \
|
||||||
-u "${{ secrets.REGISTRY_USER }}" \
|
-u "${{ secrets.REGISTRY_USER }}" \
|
||||||
--password-stdin
|
--password-stdin
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build \
|
||||||
-t git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/etf-oglasi-server:latest \
|
-t git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/etf-oglasi-server:latest \
|
||||||
backend/
|
backend/
|
||||||
|
|
||||||
- name: Push Docker image
|
- name: Push Docker image
|
||||||
run: |
|
run: |
|
||||||
docker push \
|
docker push \
|
||||||
git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/etf-oglasi-server:latest
|
git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/etf-oglasi-server:latest
|
||||||
|
|
||||||
- name: Deploy to VPS
|
- name: Deploy to VPS
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
@@ -93,14 +73,126 @@ jobs:
|
|||||||
chmod 600 ~/.ssh/key
|
chmod 600 ~/.ssh/key
|
||||||
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
|
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
# Write secrets to VPS (only if they've changed or first deploy)
|
|
||||||
ssh -i ~/.ssh/key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
|
ssh -i ~/.ssh/key \
|
||||||
|
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
|
||||||
"mkdir -p ~/programs/etf-oglasi-server/config"
|
"mkdir -p ~/programs/etf-oglasi-server/config"
|
||||||
|
|
||||||
|
|
||||||
echo "${{ secrets.FIREBASE_CREDENTIALS }}" | \
|
ssh -i ~/.ssh/key \
|
||||||
ssh -i ~/.ssh/key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
|
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
|
||||||
"cat > ~/programs/etf-oglasi-server/config/firebase.json"
|
|
||||||
|
|
||||||
ssh -i ~/.ssh/key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
|
|
||||||
"cd ~/programs/etf-oglasi-server && docker compose pull && docker compose up -d"
|
"cd ~/programs/etf-oglasi-server && docker compose pull && docker compose up -d"
|
||||||
|
|
||||||
|
mobile-release:
|
||||||
|
name: Mobile Release
|
||||||
|
needs: backend-unit-tests
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Set up JDK ${{ env.JAVA_VERSION }}
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: ${{ env.JAVA_VERSION }}
|
||||||
|
|
||||||
|
- name: Set up Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Accept Android SDK licenses
|
||||||
|
run: yes | sdkmanager --licenses > /dev/null || true
|
||||||
|
|
||||||
|
- name: Determine next mobile version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
git fetch --tags
|
||||||
|
LAST=$(git tag -l 'mobile-v*' | sed 's/mobile-v//' | sort -n | tail -1)
|
||||||
|
NEXT=$(( ${LAST:-0} + 1 ))
|
||||||
|
echo "next=$NEXT" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=mobile-v$NEXT" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Stamp app version
|
||||||
|
run: node scripts/set-mobile-version.js ${{ steps.version.outputs.next }}
|
||||||
|
|
||||||
|
- name: Restore Firebase config
|
||||||
|
run: echo "${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}" | base64 -d > google-services.json
|
||||||
|
|
||||||
|
- name: Restore env
|
||||||
|
run: echo "EXPO_PUBLIC_API_URL=${{ secrets.EXPO_PUBLIC_API_URL }}" > .env
|
||||||
|
|
||||||
|
- name: Restore signing keystore
|
||||||
|
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > ksan.dev.keystore
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Prebuild Android project
|
||||||
|
run: npx expo prebuild -p android --no-install
|
||||||
|
|
||||||
|
- name: Increase Gradle memory
|
||||||
|
run: |
|
||||||
|
echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> android/gradle.properties
|
||||||
|
|
||||||
|
|
||||||
|
- name: Build signed release APK
|
||||||
|
working-directory: frontend/android
|
||||||
|
run: |
|
||||||
|
./gradlew assembleRelease --no-daemon \
|
||||||
|
-Pandroid.injected.signing.store.file="$PWD/../ksan.dev.keystore" \
|
||||||
|
-Pandroid.injected.signing.store.password="${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" \
|
||||||
|
-Pandroid.injected.signing.key.alias="${{ secrets.ANDROID_KEY_ALIAS }}" \
|
||||||
|
-Pandroid.injected.signing.key.password="${{ secrets.ANDROID_KEY_PASSWORD }}"
|
||||||
|
|
||||||
|
- name: Locate APK
|
||||||
|
id: apk
|
||||||
|
run: |
|
||||||
|
APK=$(find android/app/build/outputs/apk/release -name "*.apk" | head -n1)
|
||||||
|
OUT="etfoglasi-${{ steps.version.outputs.tag }}.apk"
|
||||||
|
cp "$APK" "$OUT"
|
||||||
|
echo "path=$OUT" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "name=$OUT" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
run: |
|
||||||
|
API="https://git.${{ secrets.DOMAIN }}/api/v1/repos/${{ secrets.REGISTRY_USER }}/etf-oglasi"
|
||||||
|
AUTH="Authorization: token ${{ secrets.GITEARELEASES_TOKEN }}"
|
||||||
|
|
||||||
|
HTTP_CODE=$(curl -s -o release.json -w "%{http_code}" -X POST \
|
||||||
|
-H "$AUTH" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${{ steps.version.outputs.tag }}\",\"target_commitish\":\"${{ github.sha }}\",\"name\":\"${{ steps.version.outputs.tag }}\",\"draft\":false,\"prerelease\":false}" \
|
||||||
|
"$API/releases")
|
||||||
|
|
||||||
|
echo "HTTP $HTTP_CODE"
|
||||||
|
cat release.json
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" != "201" ]; then
|
||||||
|
echo "Failed to create release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_ID=$(node -pe "JSON.parse(require('fs').readFileSync('release.json','utf8')).id")
|
||||||
|
|
||||||
|
HTTP_CODE=$(curl -s -o asset.json -w "%{http_code}" -X POST \
|
||||||
|
-H "$AUTH" \
|
||||||
|
-F "attachment=@${{ steps.apk.outputs.path }}" \
|
||||||
|
"$API/releases/$RELEASE_ID/assets?name=${{ steps.apk.outputs.name }}")
|
||||||
|
|
||||||
|
echo "HTTP $HTTP_CODE"
|
||||||
|
cat asset.json
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" != "201" ]; then
|
||||||
|
echo "Failed to upload asset"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|||||||
@@ -87,3 +87,8 @@ frontend/android/
|
|||||||
|
|
||||||
# Expo
|
# Expo
|
||||||
frontend/expo-env.d.ts
|
frontend/expo-env.d.ts
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
CLAUDE.md
|
||||||
|
ksan.dev.keystore
|
||||||
|
|||||||
@@ -1,88 +1,47 @@
|
|||||||
# Full-Stack Learning Project
|
# ETF Oglasi
|
||||||
|
|
||||||
This repository is a full-stack learning project focused on building and understanding modern application development practices end-to-end.
|
<img src="logo.png" alt="ETF Oglasi logo" width="150" />
|
||||||
|
|
||||||
It demonstrates how to design, build, and deploy a real-world application using a backend API, mobile frontend, database integration, and CI/CD workflows.
|
A hobby full-stack project I built to learn modern app development end to end.
|
||||||
|
|
||||||
---
|
It scrapes the announcement boards of the Faculty of Electrical Engineering,
|
||||||
|
University of Banja Luka, stores the announcements in PostgreSQL, and lets
|
||||||
|
students browse and subscribe to subjects from a mobile app. Subscribers get
|
||||||
|
push notifications when new announcements are posted.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
### Backend
|
- **Backend:** Spring Boot 3.5 (Java 21), REST API, PostgreSQL
|
||||||
- Spring Boot
|
- **Frontend:** React Native / Expo (Expo Router, NativeWind)
|
||||||
- RESTful API architecture
|
- **Notifications:** Firebase Cloud Messaging
|
||||||
- PostgreSQL database
|
- **Tooling:** Docker, Gitea Actions for CI/CD, deployed to a self-hosted home server
|
||||||
|
|
||||||
### Frontend
|
## Project Structure
|
||||||
- React Native (Expo)
|
|
||||||
|
|
||||||
### DevOps / Tooling
|
- `backend/` — Spring Boot REST API and the scraper
|
||||||
- Docker
|
- `frontend/` — Expo mobile app
|
||||||
- CI/CD pipelines (Gitea Actions or similar)
|
|
||||||
- Git version control
|
|
||||||
|
|
||||||
---
|
## Backend
|
||||||
|
|
||||||
## Purpose
|
```bash
|
||||||
|
cd backend
|
||||||
|
cp src/main/resources/application.properties.example src/main/resources/application.properties # fill in datasource + jwt.secret
|
||||||
|
docker compose up -d # local Postgres
|
||||||
|
./gradlew bootRun
|
||||||
|
```
|
||||||
|
|
||||||
The goal of this project is to gain hands-on experience with:
|
Run tests: `./gradlew test`
|
||||||
|
|
||||||
- Full-stack application architecture
|
## Frontend
|
||||||
- Backend API development with Spring Boot
|
|
||||||
- Mobile development with React Native
|
|
||||||
- Database design and integration
|
|
||||||
- Authentication and security concepts
|
|
||||||
- Containerization with Docker
|
|
||||||
- Continuous Integration / Continuous Deployment (CI/CD)
|
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
cd frontend
|
||||||
## Deployment
|
npm install
|
||||||
|
# set EXPO_PUBLIC_API_URL in .env to the backend base URL
|
||||||
The application is deployed on a self-hosted home server for development and testing purposes.
|
npm start
|
||||||
|
```
|
||||||
---
|
|
||||||
|
|
||||||
## How to Run (Backend)
|
|
||||||
|
|
||||||
To be added.
|
|
||||||
|
|
||||||
Planned steps:
|
|
||||||
- Install Java and Gradle
|
|
||||||
- Configure PostgreSQL database
|
|
||||||
- Set environment variables
|
|
||||||
- Run Spring Boot application
|
|
||||||
- Expose API on localhost or network
|
|
||||||
- I usually run eveything with traefik but i dont know should i include that here???
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How to Run (Frontend)
|
|
||||||
|
|
||||||
To be added...
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## CI/CD
|
|
||||||
|
|
||||||
To be added.
|
|
||||||
|
|
||||||
Planned steps:
|
|
||||||
- Configure Gitea Actions / CI pipeline
|
|
||||||
- Build backend and frontend automatically
|
|
||||||
- Deploy to home server
|
|
||||||
- Automate testing and builds
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
This project is currently under semi-active development as a learning and experimentation environment.
|
Semi-actively developed as a learning project. It is an app I actually use, and
|
||||||
|
maybe others will find it useful too.
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
This project is just me messing around with stuff while making an app i will use and perhaps others will find it useful
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ CREATE TABLE IF NOT EXISTS public.users
|
|||||||
email character varying(255) COLLATE pg_catalog."default" NOT NULL,
|
email character varying(255) COLLATE pg_catalog."default" NOT NULL,
|
||||||
password character varying(255) COLLATE pg_catalog."default" NOT NULL,
|
password character varying(255) COLLATE pg_catalog."default" NOT NULL,
|
||||||
reg_time timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
reg_time timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
notification_type character varying(255) COLLATE pg_catalog."default" NOT NULL DEFAULT 'NO_NOTIFICATION'::character varying,
|
notification_type character varying(255) COLLATE pg_catalog."default" NOT NULL DEFAULT 'PUSH_NOTIFICATION'::character varying,
|
||||||
CONSTRAINT users_pkey PRIMARY KEY (id),
|
CONSTRAINT users_pkey PRIMARY KEY (id),
|
||||||
CONSTRAINT unique_email UNIQUE (email)
|
CONSTRAINT unique_email UNIQUE (email)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package dev.ksan.etfoglasiserver.config;
|
|||||||
|
|
||||||
import dev.ksan.etfoglasiserver.service.JWTService;
|
import dev.ksan.etfoglasiserver.service.JWTService;
|
||||||
import dev.ksan.etfoglasiserver.service.MyUserDetailsService;
|
import dev.ksan.etfoglasiserver.service.MyUserDetailsService;
|
||||||
import dev.ksan.etfoglasiserver.service.UserService;
|
import io.jsonwebtoken.JwtException;
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
@@ -36,7 +36,12 @@ public class JwtFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||||
token = authHeader.substring(7);
|
token = authHeader.substring(7);
|
||||||
|
try {
|
||||||
username = jwtService.extractEmail(token);
|
username = jwtService.extractEmail(token);
|
||||||
|
} catch (JwtException e) {
|
||||||
|
// expired, malformed, or otherwise invalid token - treat as unauthenticated
|
||||||
|
username = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package dev.ksan.etfoglasiserver.config;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.security.authentication.AuthenticationManager;
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
import org.springframework.security.authentication.AuthenticationProvider;
|
import org.springframework.security.authentication.AuthenticationProvider;
|
||||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||||
@@ -16,6 +17,11 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@@ -30,19 +36,39 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
|
||||||
return http.csrf(customizer -> customizer.disable()).
|
return http
|
||||||
authorizeHttpRequests(request -> request
|
.csrf(customizer -> customizer.disable())
|
||||||
|
.cors(cors -> {})
|
||||||
|
.authorizeHttpRequests(request -> request
|
||||||
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
.requestMatchers("/api/login", "/api/register").permitAll()
|
.requestMatchers("/api/login", "/api/register").permitAll()
|
||||||
.requestMatchers("/api/subjects/**", "/api/entries", "/api/groups").permitAll()
|
.requestMatchers("/api/subjects/**", "/api/entries", "/api/groups").permitAll()
|
||||||
.anyRequest().authenticated()).
|
.anyRequest().authenticated()
|
||||||
httpBasic(Customizer.withDefaults()).
|
)
|
||||||
sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.httpBasic(Customizer.withDefaults())
|
||||||
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
|
|
||||||
|
config.setAllowedOrigins(List.of(
|
||||||
|
"http://localhost:8081",
|
||||||
|
"https://etf-oglasi.ksan.dev"
|
||||||
|
));
|
||||||
|
|
||||||
|
config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
|
||||||
|
config.setAllowedHeaders(List.of("*"));
|
||||||
|
config.setAllowCredentials(true);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", config);
|
||||||
|
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public class EntryController {
|
|||||||
return service.getEntryById(entryId);
|
return service.getEntryById(entryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
//ignore this for now
|
// TODO only for testing
|
||||||
@PostMapping("/entries")
|
@PostMapping("/entries")
|
||||||
public void addEntry(@RequestBody EntryDTO entry) {
|
public void addEntry(@RequestBody EntryDTO entry) {
|
||||||
service.addEntry(entry);
|
service.addEntry(entry);
|
||||||
@@ -62,13 +62,14 @@ public class EntryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@PutMapping("/entries")
|
// @PutMapping("/entries")
|
||||||
public void updateEntry(@RequestBody EntryDTO entry) {
|
// public void updateEntry(@RequestBody EntryDTO entry) {
|
||||||
service.updateEntry(entry);
|
// service.updateEntry(entry);
|
||||||
}
|
// }
|
||||||
|
|
||||||
@DeleteMapping("/entries/{entryId}")
|
// @DeleteMapping("/entries/{entryId}")
|
||||||
public void deleteEntry(@PathVariable String entryId) {
|
// public void deleteEntry(@PathVariable String entryId) {
|
||||||
service.deleteEntry(entryId);
|
// service.deleteEntry(entryId);
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
package dev.ksan.etfoglasiserver.service;
|
package dev.ksan.etfoglasiserver.service;
|
||||||
|
|
||||||
import dev.ksan.etfoglasiserver.model.User;
|
|
||||||
import io.jsonwebtoken.Claims;
|
import io.jsonwebtoken.Claims;
|
||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import javax.crypto.KeyGenerator;
|
|
||||||
import javax.crypto.SecretKey;
|
import javax.crypto.SecretKey;
|
||||||
|
|
||||||
import io.jsonwebtoken.io.Decoders;
|
import io.jsonwebtoken.io.Decoders;
|
||||||
import io.jsonwebtoken.security.Keys;
|
import io.jsonwebtoken.security.Keys;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -29,7 +25,7 @@ public class JWTService {
|
|||||||
|
|
||||||
|
|
||||||
return Jwts.builder().claims().add(claims).subject(email)
|
return Jwts.builder().claims().add(claims).subject(email)
|
||||||
.issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis()+60*60*300))
|
.issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis()+1000L*60*60*24*60))
|
||||||
.and().signWith(getKey()).compact();
|
.and().signWith(getKey()).compact();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,16 +24,30 @@ public class NotificationService {
|
|||||||
|
|
||||||
for (DeviceToken token : tokens) {
|
for (DeviceToken token : tokens) {
|
||||||
|
|
||||||
|
// Include a notification block so Android's system tray displays it
|
||||||
|
// even when the app is killed/dozing (a data-only message is only
|
||||||
|
// ever shown by the app's own JS handler, which won't run when the
|
||||||
|
// OS has force-stopped the app). HIGH priority tells FCM to deliver
|
||||||
|
// promptly and wake the device out of Doze.
|
||||||
Message message = Message.builder()
|
Message message = Message.builder()
|
||||||
.setToken(token.getFcmToken())
|
.setToken(token.getFcmToken())
|
||||||
|
.setNotification(Notification.builder()
|
||||||
|
.setTitle(title)
|
||||||
|
.setBody(body)
|
||||||
|
.build())
|
||||||
|
.setAndroidConfig(AndroidConfig.builder()
|
||||||
|
.setPriority(AndroidConfig.Priority.HIGH)
|
||||||
|
.build())
|
||||||
.putData("title", title)
|
.putData("title", title)
|
||||||
.putData("body", body)
|
.putData("body", body)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
firebaseMessaging.send(message);
|
String id = firebaseMessaging.send(message);
|
||||||
|
System.out.println("Send:" + id);
|
||||||
} catch (FirebaseMessagingException ex) {
|
} catch (FirebaseMessagingException ex) {
|
||||||
|
|
||||||
|
ex.printStackTrace();
|
||||||
if (ex.getMessagingErrorCode()
|
if (ex.getMessagingErrorCode()
|
||||||
== MessagingErrorCode.UNREGISTERED) {
|
== MessagingErrorCode.UNREGISTERED) {
|
||||||
deviceTokenRepo.delete(token);
|
deviceTokenRepo.delete(token);
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ public class SubjectService {
|
|||||||
@Async
|
@Async
|
||||||
public void notifyAsync(Entry entry) {
|
public void notifyAsync(Entry entry) {
|
||||||
|
|
||||||
|
// The scraper couldn't always match an announcement to a known subject, in
|
||||||
|
// which case there's nobody to notify. Guard against the NPE that would
|
||||||
|
// otherwise be thrown (and silently swallowed) on this async thread.
|
||||||
|
if (entry.getSubject() == null) return;
|
||||||
|
|
||||||
List<User> users =
|
List<User> users =
|
||||||
userService.findUsersBySubjectId(entry.getSubject().getId());
|
userService.findUsersBySubjectId(entry.getSubject().getId());
|
||||||
|
|
||||||
|
|||||||
@@ -139,14 +139,15 @@ class EntryControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test
|
//we removed this from controller so test is pointless
|
||||||
@WithMockUser
|
// @Test
|
||||||
void deleteEntry_authenticated_returns200() throws Exception {
|
// @WithMockUser
|
||||||
doNothing().when(entryService).deleteEntry("entry-1");
|
// void deleteEntry_authenticated_returns200() throws Exception {
|
||||||
|
// doNothing().when(entryService).deleteEntry("entry-1");
|
||||||
mvc.perform(delete("/api/entries/entry-1"))
|
//
|
||||||
.andExpect(status().isOk());
|
// mvc.perform(delete("/api/entries/entry-1"))
|
||||||
}
|
// .andExpect(status().isOk());
|
||||||
|
// }
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteEntry_unauthenticated_returns401or403() throws Exception {
|
void deleteEntry_unauthenticated_returns401or403() throws Exception {
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
name: CI - full
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backend-test:
|
|
||||||
uses: ./.gitea/workflows/backend-test.yaml
|
|
||||||
|
|
||||||
frontend-build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: frontend
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
- name: Build frontend
|
|
||||||
run: npx expo export --platform web
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "etfoglasi",
|
"name": "etfoglasi",
|
||||||
"slug": "etfoglasi",
|
"slug": "etfoglasi",
|
||||||
"version": "1.0.0",
|
"version": "1.0.6",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "etfoglasi",
|
"scheme": "etfoglasi",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"android": {
|
"android": {
|
||||||
"googleServicesFile": "./google-services.json",
|
"googleServicesFile": "./google-services.json",
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#1C1918",
|
||||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import "../globals.css";
|
import "../globals.css";
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
import {
|
import {
|
||||||
@@ -10,8 +10,13 @@ import {
|
|||||||
} from '@react-native-firebase/messaging';
|
} from '@react-native-firebase/messaging';
|
||||||
import { registerDeviceToken, listenForTokenRefresh } from '@/services/notifications';
|
import { registerDeviceToken, listenForTokenRefresh } from '@/services/notifications';
|
||||||
import { AuthProvider, useAuth } from "@/context/AuthContext";
|
import { AuthProvider, useAuth } from "@/context/AuthContext";
|
||||||
|
import { useUpdatecheck } from '@/hooks/useUpdatecheck';
|
||||||
|
import { UpdatePrompt } from '@/components/UpdatePrompt';
|
||||||
import Toast from 'react-native-toast-message';
|
import Toast from 'react-native-toast-message';
|
||||||
|
|
||||||
|
// The FCM background handler now lives in index.js (registered before this
|
||||||
|
// module loads) so it fires reliably while the app is backgrounded/killed.
|
||||||
|
|
||||||
function NotificationSetup() {
|
function NotificationSetup() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
@@ -27,7 +32,8 @@ function NotificationSetup() {
|
|||||||
console.log('Foreground notification:', msg);
|
console.log('Foreground notification:', msg);
|
||||||
Toast.show({
|
Toast.show({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
text1: msg.notification?.title ?? 'New Notification',
|
text1: (msg.data?.title as string | undefined) ?? 'New Notification',
|
||||||
|
text2: msg.data?.body as string | undefined,
|
||||||
position: 'top',
|
position: 'top',
|
||||||
visibilityTime: 4000,
|
visibilityTime: 4000,
|
||||||
});
|
});
|
||||||
@@ -50,12 +56,22 @@ function NotificationSetup() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UpdateCheck() {
|
||||||
|
const { updateInfo } = useUpdatecheck();
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
if (!updateInfo || dismissed) return null;
|
||||||
|
|
||||||
|
return <UpdatePrompt updateInfo={updateInfo} onDismiss={() => setDismissed(true)} />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<NotificationSetup />
|
<NotificationSetup />
|
||||||
<Stack screenOptions={{ headerShown: false }} />
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
|
<UpdateCheck />
|
||||||
<Toast />
|
<Toast />
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 916 KiB |
@@ -0,0 +1,138 @@
|
|||||||
|
// components/UpdatePrompt.tsx
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Modal that appears when a newer build is available.
|
||||||
|
// "Update now" opens the Gitea release page in the browser.
|
||||||
|
// "Later" dismisses it for the current session (it reappears next launch).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Linking,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import type { UpdateInfo } from '../hooks/useUpdatecheck';
|
||||||
|
import { version as currentVersion } from '../version.json';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
updateInfo: UpdateInfo;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdatePrompt({ updateInfo, onDismiss }: Props) {
|
||||||
|
function openRelease() {
|
||||||
|
Linking.openURL(updateInfo.releaseUrl).catch(() => {
|
||||||
|
// If the device can't open the URL, just dismiss so the app isn't stuck.
|
||||||
|
onDismiss();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={onDismiss}
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>Update available</Text>
|
||||||
|
|
||||||
|
<Text style={styles.body}>
|
||||||
|
A new version of the app is available.{'\n'}
|
||||||
|
You are on build <Text style={styles.bold}>{currentVersion}</Text>,
|
||||||
|
the latest is build{' '}
|
||||||
|
<Text style={styles.bold}>{updateInfo.latestVersion}</Text>.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.btn, styles.btnSecondary]}
|
||||||
|
onPress={onDismiss}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Remind me later"
|
||||||
|
>
|
||||||
|
<Text style={styles.btnSecondaryText}>Later</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.btn, styles.btnPrimary]}
|
||||||
|
onPress={openRelease}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Open the release page to update"
|
||||||
|
>
|
||||||
|
<Text style={styles.btnPrimaryText}>Update now</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 380,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 24,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#111',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#444',
|
||||||
|
lineHeight: 22,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
bold: {
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#111',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
btnPrimary: {
|
||||||
|
backgroundColor: '#2563EB',
|
||||||
|
},
|
||||||
|
btnPrimaryText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
btnSecondary: {
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
btnSecondaryText: {
|
||||||
|
color: '#374151',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const GITEA_URL = 'https://git.ksan.dev';
|
||||||
|
|
||||||
|
export const GITEA_OWNER = 'ksan';
|
||||||
|
|
||||||
|
export const GITEA_REPO = 'etf-oglasi';
|
||||||
|
|
||||||
|
|
||||||
|
export const RELEASE_TAG_PREFIX = 'mobile-v';
|
||||||
|
|
||||||
|
export const UPDATE_CHECK_TIMEOUT_MS = 8_000;
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import Toast from "react-native-toast-message";
|
||||||
import {
|
import {
|
||||||
authApi,
|
authApi,
|
||||||
|
|
||||||
LoginPayload, RegisterPayload, subscriptionsApi, userApi, UserUpdateDTO,
|
LoginPayload, RegisterPayload, setUnauthorizedHandler, subscriptionsApi, userApi, UserUpdateDTO,
|
||||||
} from "@/services/api";
|
} from "@/services/api";
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
@@ -45,6 +46,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setUser(u);
|
setUser(u);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUnauthorizedHandler(() => {
|
||||||
|
persist(null);
|
||||||
|
Toast.show({
|
||||||
|
type: 'info',
|
||||||
|
text1: 'Session expired',
|
||||||
|
text2: 'Please sign in again.',
|
||||||
|
position: 'top',
|
||||||
|
visibilityTime: 4000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => setUnauthorizedHandler(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const updateNotificationType = async (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => {
|
const updateNotificationType = async (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
await userApi.updateNotificationType(type, user.token);
|
await userApi.updateNotificationType(type, user.token);
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
GITEA_URL,
|
||||||
|
GITEA_OWNER,
|
||||||
|
GITEA_REPO,
|
||||||
|
RELEASE_TAG_PREFIX,
|
||||||
|
UPDATE_CHECK_TIMEOUT_MS,
|
||||||
|
} from '../config/gitea';
|
||||||
|
import { version as currentVersion } from '../version.json';
|
||||||
|
|
||||||
|
export interface UpdateInfo {
|
||||||
|
/** Build number of the latest release (same unit as currentVersion). */
|
||||||
|
latestVersion: number;
|
||||||
|
/** Direct URL the user can open to read the release notes / download the APK. */
|
||||||
|
releaseUrl: string;
|
||||||
|
/** Tag name as stored on Gitea, e.g. "mobile-v42". */
|
||||||
|
tagName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseUpdateCheckResult {
|
||||||
|
/** True while the API call is in flight. */
|
||||||
|
checking: boolean;
|
||||||
|
/** Populated once the check completes and a newer build exists. */
|
||||||
|
updateInfo: UpdateInfo | null;
|
||||||
|
/** Non-null when the check failed (network error, timeout, bad response). */
|
||||||
|
error: Error | null;
|
||||||
|
/** Re-run the check manually (e.g. from a "check again" button). */
|
||||||
|
recheck: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Gitea releases API ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface GiteaRelease {
|
||||||
|
id: number;
|
||||||
|
tag_name: string;
|
||||||
|
name: string;
|
||||||
|
html_url: string;
|
||||||
|
draft: boolean;
|
||||||
|
prerelease: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLatestRelease(): Promise<GiteaRelease> {
|
||||||
|
const url = `${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/latest`;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { signal: controller.signal });
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Gitea API returned HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await res.json()) as GiteaRelease;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the integer build number out of a tag like "mobile-v42". Returns NaN if it can't. */
|
||||||
|
function parseBuildNumber(tagName: string): number {
|
||||||
|
if (!tagName.startsWith(RELEASE_TAG_PREFIX)) return NaN;
|
||||||
|
return parseInt(tagName.slice(RELEASE_TAG_PREFIX.length), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function useUpdatecheck(): UseUpdateCheckResult {
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
// A counter we bump to re-trigger the effect without changing the dep array shape.
|
||||||
|
const [trigger, setTrigger] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
setChecking(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const release = await fetchLatestRelease();
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// Skip drafts and pre-releases.
|
||||||
|
if (release.draft || release.prerelease) {
|
||||||
|
setUpdateInfo(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestVersion = parseBuildNumber(release.tag_name);
|
||||||
|
|
||||||
|
if (isNaN(latestVersion)) {
|
||||||
|
// The tag doesn't follow our scheme — ignore it silently.
|
||||||
|
setUpdateInfo(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestVersion > currentVersion) {
|
||||||
|
setUpdateInfo({
|
||||||
|
latestVersion,
|
||||||
|
releaseUrl: release.html_url,
|
||||||
|
tagName: release.tag_name,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setUpdateInfo(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
// Don't surface AbortError as a real error — it's just a timeout.
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') {
|
||||||
|
setError(new Error('Update check timed out. Check your connection.'));
|
||||||
|
} else {
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setChecking(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [trigger]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
checking,
|
||||||
|
updateInfo,
|
||||||
|
error,
|
||||||
|
recheck: () => setTrigger(t => t + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Custom entry point. The FCM background handler must be registered in the very
|
||||||
|
// first module that loads — before the router/app mounts — so it reliably fires
|
||||||
|
// when the app is backgrounded or fully quit. Registering it here (rather than in
|
||||||
|
// app/_layout.tsx) is what react-native-firebase requires for the killed state.
|
||||||
|
import messaging from '@react-native-firebase/messaging';
|
||||||
|
import notifee, { AndroidImportance } from '@notifee/react-native';
|
||||||
|
|
||||||
|
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
|
||||||
|
const title = remoteMessage.data?.title;
|
||||||
|
const body = remoteMessage.data?.body;
|
||||||
|
if (!title && !body) return;
|
||||||
|
|
||||||
|
const channelId = await notifee.createChannel({
|
||||||
|
id: 'default',
|
||||||
|
name: 'General',
|
||||||
|
importance: AndroidImportance.HIGH,
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifee.displayNotification({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
android: { channelId, pressAction: { id: 'default' } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hand off to expo-router, which mounts app/_layout.tsx and the routes.
|
||||||
|
import 'expo-router/entry';
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { useColorScheme } from "react-native";
|
|
||||||
import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native";
|
|
||||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
|
|
||||||
import SubscribedFeed from "@/screens/SubscribedFeed";
|
|
||||||
import AllFeed from "@/screens/AllFeed";
|
|
||||||
import Profile from "@/screens/Profile";
|
|
||||||
import {enableScreens} from "react-native-screens";
|
|
||||||
import {SafeAreaProvider} from "react-native-safe-area-context";
|
|
||||||
import AuthGate from "@/screens/AuthGate";
|
|
||||||
import {AuthProvider, useAuth} from "@/context/AuthContext";
|
|
||||||
import messaging, {setBackgroundMessageHandler} from "@react-native-firebase/messaging";
|
|
||||||
import {getMessaging} from "@firebase/messaging";
|
|
||||||
messaging().setBackgroundMessageHandler(async () => {});
|
|
||||||
// Must be called before any navigator renders
|
|
||||||
enableScreens();
|
|
||||||
|
|
||||||
const Tab = createBottomTabNavigator();
|
|
||||||
|
|
||||||
const LIGHT_TAB = {
|
|
||||||
bg: "#FFFFFF",
|
|
||||||
border: "#E8E2D5",
|
|
||||||
active: "#C4622D",
|
|
||||||
inactive: "#8A8278",
|
|
||||||
label: "#1A1714",
|
|
||||||
};
|
|
||||||
const DARK_TAB = {
|
|
||||||
bg: "#161513",
|
|
||||||
border: "#2C2A27",
|
|
||||||
active: "#E07B45",
|
|
||||||
inactive: "#706D67",
|
|
||||||
label: "#F0EDE8",
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
function ProfileTab() {
|
|
||||||
const { user, loading } = useAuth();
|
|
||||||
if (loading) return null;
|
|
||||||
return user ? <Profile /> : <AuthGate />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const scheme = useColorScheme();
|
|
||||||
const dark = scheme === "dark";
|
|
||||||
const tab = dark ? DARK_TAB : LIGHT_TAB;
|
|
||||||
|
|
||||||
const navTheme = dark
|
|
||||||
? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "#0E0D0C" } }
|
|
||||||
: { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthProvider>
|
|
||||||
<SafeAreaProvider>
|
|
||||||
<NavigationContainer theme={navTheme}>
|
|
||||||
<Tab.Navigator
|
|
||||||
screenOptions={({ route }) => ({
|
|
||||||
headerShown: false,
|
|
||||||
|
|
||||||
tabBarStyle: {
|
|
||||||
backgroundColor: tab.bg,
|
|
||||||
borderTopColor: tab.border,
|
|
||||||
borderTopWidth: 1,
|
|
||||||
height: 60,
|
|
||||||
paddingBottom: 8,
|
|
||||||
paddingTop: 6,
|
|
||||||
},
|
|
||||||
|
|
||||||
tabBarActiveTintColor: tab.active,
|
|
||||||
tabBarInactiveTintColor: tab.inactive,
|
|
||||||
|
|
||||||
tabBarLabelStyle: {
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: "600",
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
},
|
|
||||||
|
|
||||||
tabBarIcon: ({ focused, color, size }) => {
|
|
||||||
const icons: Record<
|
|
||||||
string,
|
|
||||||
{ active: keyof typeof Ionicons.glyphMap; inactive: keyof typeof Ionicons.glyphMap }
|
|
||||||
> = {
|
|
||||||
"Subscribed": { active: "bookmark", inactive: "bookmark-outline" },
|
|
||||||
"Discover": { active: "compass", inactive: "compass-outline" },
|
|
||||||
"Profile": { active: "person-circle", inactive: "person-circle-outline" },
|
|
||||||
};
|
|
||||||
const set = icons[route.name];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Ionicons
|
|
||||||
name={focused ? set.active : set.inactive}
|
|
||||||
size={focused ? size + 1 : size}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Subscribed"
|
|
||||||
component={SubscribedFeed}
|
|
||||||
options={{ title: "My Feed" }}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Discover"
|
|
||||||
component={AllFeed}
|
|
||||||
options={{ title: "Discover" }}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Profile"
|
|
||||||
component={ProfileTab}
|
|
||||||
options={{ title: "Profile" }}
|
|
||||||
/>
|
|
||||||
</Tab.Navigator>
|
|
||||||
</NavigationContainer>
|
|
||||||
</SafeAreaProvider>
|
|
||||||
|
|
||||||
</AuthProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@notifee/react-native": "^9.1.8",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-native-firebase/app": "^24.0.0",
|
"@react-native-firebase/app": "^24.0.0",
|
||||||
"@react-native-firebase/messaging": "^24.0.0",
|
"@react-native-firebase/messaging": "^24.0.0",
|
||||||
@@ -3583,6 +3584,15 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@notifee/react-native": {
|
||||||
|
"version": "9.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@notifee/react-native/-/react-native-9.1.8.tgz",
|
||||||
|
"integrity": "sha512-Az/dueoPerJsbbjRxu8a558wKY+gONUrfoy3Hs++5OqbeMsR0dYe6P+4oN6twrLFyzAhEA1tEoZRvQTFDRmvQg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@protobufjs/aspromise": {
|
"node_modules/@protobufjs/aspromise": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "etfoglasi",
|
"name": "etfoglasi",
|
||||||
"main": "expo-router/entry",
|
"main": "index.js",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@notifee/react-native": "^9.1.8",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-native-firebase/app": "^24.0.0",
|
"@react-native-firebase/app": "^24.0.0",
|
||||||
"@react-native-firebase/messaging": "^24.0.0",
|
"@react-native-firebase/messaging": "^24.0.0",
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import Constants from "expo-constants";
|
||||||
|
|
||||||
const CONTACT_EMAIL = "contact@ksan.dev";
|
const CONTACT_EMAIL = "contact@ksan.dev";
|
||||||
//TODO make github page because yea
|
//TODO make github page because yea
|
||||||
const GITHUB_URL = "https://git.ksan.dev/ksan";
|
const GITHUB_URL = "https://git.ksan.dev/ksan";
|
||||||
const APP_VERSION = "1.0.0";
|
// Sourced from app.json's expo.version, which CI stamps on every mobile
|
||||||
|
// release (scripts/set-mobile-version.js), so this stays in sync automatically.
|
||||||
|
const APP_VERSION = Constants.expoConfig?.version ?? "unknown";
|
||||||
|
|
||||||
export default function HelpSupport({ onClose }: { onClose: () => void }) {
|
export default function HelpSupport({ onClose }: { onClose: () => void }) {
|
||||||
const dark = useColorScheme() === "dark";
|
const dark = useColorScheme() === "dark";
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Used by CI to stamp a mobile release build with its version number.
|
||||||
|
// Updates version.json (read by hooks/useUpdatecheck.tsx) and app.json's
|
||||||
|
// android.versionCode/versionName so the build, the in-app update check,
|
||||||
|
// and the Gitea release tag (mobile-vN) all agree on the same number.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const version = parseInt(process.argv[2], 10);
|
||||||
|
if (!Number.isInteger(version) || version <= 0) {
|
||||||
|
console.error('Usage: node set-mobile-version.js <positive integer>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'version.json'),
|
||||||
|
JSON.stringify({ version }, null, 2) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const appJsonPath = path.join(root, 'app.json');
|
||||||
|
const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf8'));
|
||||||
|
appJson.expo.version = `1.0.${version}`;
|
||||||
|
appJson.expo.android = appJson.expo.android ?? {};
|
||||||
|
appJson.expo.android.versionCode = version;
|
||||||
|
fs.writeFileSync(appJsonPath, JSON.stringify(appJson, null, 2) + '\n');
|
||||||
|
|
||||||
|
console.log(`Stamped mobile version ${version}`);
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
|
|
||||||
const BASE_URL = process.env.EXPO_PUBLIC_API_URL; // TODO change this
|
const BASE_URL = process.env.EXPO_PUBLIC_API_URL+'/api';
|
||||||
|
|
||||||
export type NotificationType = 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION';
|
export type NotificationType = 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION';
|
||||||
function authHeader(token?: string) {
|
function authHeader(token?: string) {
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let onUnauthorized: (() => void) | null = null;
|
||||||
|
|
||||||
|
// Called once from AuthProvider so api.tsx can trigger a logout/relogin
|
||||||
|
// when the backend rejects a request due to a missing/expired/invalid token.
|
||||||
|
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||||
|
onUnauthorized = handler;
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -30,6 +38,7 @@ async function request<T>(
|
|||||||
console.log('Failed URL:', res.url);
|
console.log('Failed URL:', res.url);
|
||||||
console.log('Status:', res.status);
|
console.log('Status:', res.status);
|
||||||
console.log('Response:', errorText);
|
console.log('Response:', errorText);
|
||||||
|
if (res.status === 401 && token) onUnauthorized?.();
|
||||||
throw new Error(errorText);
|
throw new Error(errorText);
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
onTokenRefresh,
|
onTokenRefresh,
|
||||||
AuthorizationStatus,
|
AuthorizationStatus,
|
||||||
} from '@react-native-firebase/messaging';
|
} from '@react-native-firebase/messaging';
|
||||||
|
import notifee, { AndroidImportance } from '@notifee/react-native';
|
||||||
import { post } from '@/services/api';
|
import { post } from '@/services/api';
|
||||||
|
|
||||||
export async function registerDeviceToken(authToken: string): Promise<void> {
|
export async function registerDeviceToken(authToken: string): Promise<void> {
|
||||||
@@ -18,7 +19,7 @@ export async function registerDeviceToken(authToken: string): Promise<void> {
|
|||||||
|
|
||||||
const fcmToken = await getToken(messaging);
|
const fcmToken = await getToken(messaging);
|
||||||
|
|
||||||
await fetch(`${process.env.EXPO_PUBLIC_API_URL}/device-tokens/register`, {
|
await fetch(`${process.env.EXPO_PUBLIC_API_URL}/api/device-tokens/register`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -33,3 +34,22 @@ export function listenForTokenRefresh(authToken: string): () => void {
|
|||||||
await post('/device-tokens/register', { token: newToken }, authToken);
|
await post('/device-tokens/register', { token: newToken }, authToken);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function displayLocalNotification(title?: string, body?: string) {
|
||||||
|
if (!title && !body) return;
|
||||||
|
|
||||||
|
const channelId = await notifee.createChannel({
|
||||||
|
id: 'default',
|
||||||
|
name: 'General',
|
||||||
|
importance: AndroidImportance.HIGH,
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifee.displayNotification({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
android: {
|
||||||
|
channelId,
|
||||||
|
pressAction: { id: 'default' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"version": 0
|
||||||
|
}
|
||||||