11 Commits

Author SHA1 Message Date
ksan 194d598d6d fixing spelling mistake
CI/CD / Backend Unit Tests (push) Successful in 2m13s
CI/CD / Deploy (push) Successful in 2m16s
CI/CD / Mobile Release (push) Failing after 21m26s
2026-06-11 17:23:21 +02:00
ksan d76434014f aaa
CI/CD / Backend Unit Tests (push) Successful in 2m8s
CI/CD / Deploy (push) Successful in 2m7s
CI/CD / Mobile Release (push) Failing after 21m35s
2026-06-11 16:51:29 +02:00
ksan 266802088a fixing
CI/CD / Backend Unit Tests (push) Successful in 2m8s
CI/CD / Deploy (push) Successful in 2m9s
CI/CD / Mobile Release (push) Has been cancelled
2026-06-11 16:43:38 +02:00
ksan 6c98e4a469 updated ci file
CI/CD / Backend Unit Tests (push) Successful in 2m12s
CI/CD / Deploy (push) Successful in 2m6s
CI/CD / Mobile Release (push) Failing after 1m12s
2026-06-11 16:33:16 +02:00
ksan 5d2898dd13 fixing error
CI/CD / Backend Unit Tests (push) Successful in 2m20s
CI/CD / Deploy (push) Successful in 2m8s
CI/CD / Mobile Release (push) Failing after 25m30s
2026-06-11 15:47:48 +02:00
ksan 694b68cbc5 updated ci/cd to add releases and testing claude code on my project
CI/CD / Backend Unit Tests (push) Successful in 2m11s
CI/CD / Deploy (push) Successful in 2m0s
CI/CD / Mobile Release (push) Failing after 21m39s
2026-06-11 15:11:09 +02:00
ksan 0aa633a483 added update check
CI/CD / Backend Unit Tests (push) Successful in 2m29s
CI/CD / Deploy (push) Successful in 2m16s
2026-06-11 14:22:08 +02:00
ksan 8404bcafd0 removed test that was not needed
CI/CD / Backend Unit Tests (push) Successful in 2m14s
CI/CD / Deploy (push) Successful in 2m10s
2026-06-11 14:01:16 +02:00
ksan e3529f892c final touches
CI/CD / Deploy (push) Has been cancelled
CI/CD / Backend Unit Tests (push) Has been cancelled
2026-06-11 13:54:10 +02:00
ksan 7d7e641f5b fixed push notifications and auth modified
CI/CD / Backend Unit Tests (push) Successful in 2m17s
CI/CD / Deploy (push) Successful in 2m26s
2026-06-11 13:30:36 +02:00
ksan 3afe5e2931 switching branch 2026-06-11 11:54:14 +02:00
13 changed files with 303 additions and 62 deletions
+114
View File
@@ -82,3 +82,117 @@ jobs:
ssh -i ~/.ssh/key \ ssh -i ~/.ssh/key \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ "${{ 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=frontend/$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
+5
View File
@@ -87,3 +87,8 @@ frontend/android/
# Expo # Expo
frontend/expo-env.d.ts frontend/expo-env.d.ts
CLAUDE.md
ksan.dev.keystore
+3
View File
@@ -86,3 +86,6 @@ This project is currently under semi-active development as a learning and experi
## Notes ## Notes
This project is just me messing around with stuff while making an app i will use and perhaps others will find it useful This project is just me messing around with stuff while making an app i will use and perhaps others will find it useful
need to made .creds .env and init directory with subjects.txt
@@ -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) {
@@ -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();
} }
@@ -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 {
+25 -3
View File
@@ -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 {
@@ -7,11 +7,22 @@ import {
onMessage, onMessage,
onNotificationOpenedApp, onNotificationOpenedApp,
getInitialNotification, getInitialNotification,
setBackgroundMessageHandler,
} from '@react-native-firebase/messaging'; } from '@react-native-firebase/messaging';
import { registerDeviceToken, listenForTokenRefresh } from '@/services/notifications'; import { registerDeviceToken, listenForTokenRefresh, displayLocalNotification } 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';
// Registered at module scope so it's installed as soon as this entry file
// loads, which is required for it to fire while the app is backgrounded/killed.
setBackgroundMessageHandler(getMessaging(), async (remoteMessage) => {
const title = remoteMessage.data?.title as string | undefined;
const body = remoteMessage.data?.body as string | undefined;
await displayLocalNotification(title, body);
});
function NotificationSetup() { function NotificationSetup() {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@@ -27,7 +38,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 +62,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>
+16 -1
View File
@@ -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);
+31 -9
View File
@@ -7,24 +7,47 @@ import { useColorScheme } from "react-native";
import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native"; import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { enableScreens } from "react-native-screens";
import { SafeAreaProvider } from "react-native-safe-area-context";
import messaging from "@react-native-firebase/messaging";
import notifee, { AndroidImportance } from "@notifee/react-native";
import SubscribedFeed from "@/screens/SubscribedFeed"; import SubscribedFeed from "@/screens/SubscribedFeed";
import AllFeed from "@/screens/AllFeed"; import AllFeed from "@/screens/AllFeed";
import Profile from "@/screens/Profile"; 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 AuthGate from "@/screens/AuthGate";
import { AuthProvider, useAuth } from "@/context/AuthContext"; import { AuthProvider, useAuth } from "@/context/AuthContext";
import messaging, {setBackgroundMessageHandler} from "@react-native-firebase/messaging";
import { useUpdatecheck } from "./hooks/useUpdatecheck"; import { useUpdatecheck } from "./hooks/useUpdatecheck";
import { UpdatePrompt } from './components/UpdatePrompt'; import { UpdatePrompt } from "./components/UpdatePrompt";
import { usePushNotifications } from "./hooks/usePushNotifications"; import { usePushNotifications } from "./hooks/usePushNotifications";
messaging().setBackgroundMessageHandler(async () => {}); // Handles data-only FCM messages when the app is in the background or closed.
messaging().setBackgroundMessageHandler(async remoteMessage => {
const title = remoteMessage.data?.title as string | undefined;
const body = remoteMessage.data?.body as string | undefined;
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" },
},
});
});
// Must be called before any navigator renders // Must be called before any navigator renders
enableScreens(); enableScreens();
const Tab = createBottomTabNavigator(); const Tab = createBottomTabNavigator();
const LIGHT_TAB = { const LIGHT_TAB = {
@@ -34,6 +57,7 @@ const LIGHT_TAB = {
inactive: "#8A8278", inactive: "#8A8278",
label: "#1A1714", label: "#1A1714",
}; };
const DARK_TAB = { const DARK_TAB = {
bg: "#161513", bg: "#161513",
border: "#2C2A27", border: "#2C2A27",
@@ -42,7 +66,6 @@ const DARK_TAB = {
label: "#F0EDE8", label: "#F0EDE8",
}; };
function ProfileTab() { function ProfileTab() {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
if (loading) return null; if (loading) return null;
@@ -62,7 +85,6 @@ export default function App() {
const { updateInfo } = useUpdatecheck(); const { updateInfo } = useUpdatecheck();
const [updateDismissed, setUpdateDismissed] = useState(false); const [updateDismissed, setUpdateDismissed] = useState(false);
usePushNotifications(); usePushNotifications();
return ( return (
@@ -130,7 +152,7 @@ export default function App() {
</Tab.Navigator> </Tab.Navigator>
</NavigationContainer> </NavigationContainer>
{/* Overlays the entire app, including the nav bar */} {/* Overlays the entire app including the tab bar */}
{updateInfo && !updateDismissed && ( {updateInfo && !updateDismissed && (
<UpdatePrompt <UpdatePrompt
updateInfo={updateInfo} updateInfo={updateInfo}
+28
View File
@@ -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}`);
+9
View File
@@ -6,6 +6,14 @@ 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();
+20
View File
@@ -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> {
@@ -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' },
},
});
}