Compare commits

...

9 Commits

Author SHA1 Message Date
Leon Bösche
5caf3f6b62 idle 2026-01-08 22:09:52 +01:00
Leon Bösche
b18a171ac2 idle 2026-01-08 22:09:45 +01:00
Leon Bösche
37e1c1a616 Add session persistence with shared_preferences - maintain login across page refreshes 2026-01-08 22:08:23 +01:00
Leon Bösche
6a01fe84ac Fix AuthAuthenticated email access 2026-01-08 21:42:04 +01:00
Leon Bösche
7adde54a41 Support personal workspace without requiring organization 2026-01-08 21:40:55 +01:00
Leon Bösche
1eb8781550 Add CORS middleware to handle browser preflight requests 2026-01-08 21:32:34 +01:00
Leon Bösche
352e3ee6c5 idle 2026-01-08 20:45:23 +01:00
Leon Bösche
1930eb37fb Fix chi router middleware ordering - move auth middleware to protected routes subrouter 2026-01-08 20:40:07 +01:00
Leon Bösche
912fc99e9e idle 2026-01-08 20:29:22 +01:00
12 changed files with 230 additions and 62 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

View File

@@ -1,6 +1,7 @@
import 'package:bloc/bloc.dart';
import '../session/session_bloc.dart';
import '../session/session_event.dart';
import '../session/session_state.dart';
import 'auth_event.dart';
import 'auth_state.dart';
import '../../services/api_client.dart';
@@ -252,7 +253,22 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
CheckAuthRequested event,
Emitter<AuthState> emit,
) async {
// Check if token is valid in SessionBloc
emit(AuthUnauthenticated());
// Check if session is active from persistent storage
final sessionState = sessionBloc.state;
if (sessionState is SessionActive) {
// Session already active - emit authenticated state with minimal info
// The full user info will be fetched when needed
emit(
AuthAuthenticated(
token: sessionState.token,
userId: '',
username: '',
email: '',
),
);
} else {
emit(AuthUnauthenticated());
}
}
}

View File

@@ -1,42 +1,98 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'session_event.dart';
import 'session_state.dart';
class SessionBloc extends Bloc<SessionEvent, SessionState> {
Timer? _expiryTimer;
static const String _tokenKey = 'auth_token';
static const String _expiryKey = 'auth_expiry';
SessionBloc() : super(SessionInitial()) {
on<SessionStarted>(_onSessionStarted);
on<SessionExpired>(_onSessionExpired);
on<SessionRefreshed>(_onSessionRefreshed);
on<SessionEnded>(_onSessionEnded);
on<SessionRestored>(_onSessionRestored);
}
void _onSessionStarted(SessionStarted event, Emitter<SessionState> emit) {
void _onSessionStarted(SessionStarted event, Emitter<SessionState> emit) async {
final expiresAt = DateTime.now().add(
const Duration(minutes: 15),
); // Match Go
// Save token to persistent storage
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, event.token);
await prefs.setString(_expiryKey, expiresAt.toIso8601String());
emit(SessionActive(token: event.token, expiresAt: expiresAt));
_startExpiryTimer(expiresAt);
}
void _onSessionExpired(SessionExpired event, Emitter<SessionState> emit) {
_expiryTimer?.cancel();
_clearStoredSession();
emit(SessionExpiredState());
}
void _onSessionRefreshed(SessionRefreshed event, Emitter<SessionState> emit) {
void _onSessionRefreshed(SessionRefreshed event, Emitter<SessionState> emit) async {
final expiresAt = DateTime.now().add(const Duration(minutes: 15));
// Update stored token
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, event.newToken);
await prefs.setString(_expiryKey, expiresAt.toIso8601String());
emit(SessionActive(token: event.newToken, expiresAt: expiresAt));
_startExpiryTimer(expiresAt);
}
void _onSessionEnded(SessionEnded event, Emitter<SessionState> emit) {
_expiryTimer?.cancel();
_clearStoredSession();
emit(SessionInitial());
}
void _onSessionRestored(SessionRestored event, Emitter<SessionState> emit) {
final expiresAt = event.expiresAt;
final now = DateTime.now();
// Check if token is still valid
if (expiresAt.isAfter(now)) {
emit(SessionActive(token: event.token, expiresAt: expiresAt));
_startExpiryTimer(expiresAt);
} else {
// Token expired, clear it
_clearStoredSession();
emit(SessionInitial());
}
}
Future<void> _clearStoredSession() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_expiryKey);
}
static Future<void> restoreSession(SessionBloc bloc) async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(_tokenKey);
final expiryStr = prefs.getString(_expiryKey);
if (token != null && expiryStr != null) {
try {
final expiresAt = DateTime.parse(expiryStr);
bloc.add(SessionRestored(token: token, expiresAt: expiresAt));
} catch (e) {
// Invalid stored data, clear it
await prefs.remove(_tokenKey);
await prefs.remove(_expiryKey);
}
}
}
void _startExpiryTimer(DateTime expiresAt) {
_expiryTimer?.cancel();
final duration = expiresAt.difference(DateTime.now());

View File

@@ -28,3 +28,13 @@ class SessionRefreshed extends SessionEvent {
}
class SessionEnded extends SessionEvent {}
class SessionRestored extends SessionEvent {
final String token;
final DateTime expiresAt;
const SessionRestored({required this.token, required this.expiresAt});
@override
List<Object> get props => [token, expiresAt];
}

View File

@@ -41,23 +41,37 @@ void main() {
runApp(const MainApp());
}
class MainApp extends StatelessWidget {
class MainApp extends StatefulWidget {
const MainApp({super.key});
@override
State<MainApp> createState() => _MainAppState();
}
class _MainAppState extends State<MainApp> {
final _sessionBloc = SessionBloc();
late final AuthBloc _authBloc;
@override
void initState() {
super.initState();
_authBloc = AuthBloc(
apiClient: ApiClient(_sessionBloc),
sessionBloc: _sessionBloc,
);
// Restore session from persistent storage
SessionBloc.restoreSession(_sessionBloc);
}
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<SessionBloc>(create: (_) => SessionBloc()),
BlocProvider<AuthBloc>(
create: (context) => AuthBloc(
apiClient: ApiClient(context.read<SessionBloc>()),
sessionBloc: context.read<SessionBloc>(),
),
),
BlocProvider<SessionBloc>.value(value: _sessionBloc),
BlocProvider<AuthBloc>.value(value: _authBloc),
BlocProvider<ActivityBloc>(
create: (context) =>
ActivityBloc(ActivityApi(ApiClient(context.read<SessionBloc>()))),
ActivityBloc(ActivityApi(ApiClient(_sessionBloc))),
),
],
child: MaterialApp.router(
@@ -66,4 +80,11 @@ class MainApp extends StatelessWidget {
),
);
}
@override
void dispose() {
_authBloc.close();
_sessionBloc.close();
super.dispose();
}
}

View File

@@ -190,10 +190,16 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
);
}
Widget _buildDrive(OrganizationState state) {
return state is OrganizationLoaded && state.selectedOrg != null
? FileExplorer(orgId: state.selectedOrg!.id)
: const FileExplorer(orgId: 'org1');
Widget _buildDrive(OrganizationState state, AuthState authState) {
if (state is OrganizationLoaded && state.selectedOrg != null) {
// Show selected organization's files
return FileExplorer(orgId: state.selectedOrg!.id);
} else if (authState is AuthAuthenticated) {
// Show personal workspace using user's email as workspace ID
return FileExplorer(orgId: authState.email);
} else {
return const FileExplorer(orgId: 'personal');
}
}
Widget _buildNavButton(String label, IconData icon, {bool isAvatar = false}) {
@@ -313,6 +319,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
Expanded(
child: _buildDrive(
orgState,
state,
),
),
],

32
go_cloud/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# ---------- Build stage ----------
FROM golang:1.24-alpine AS builder
WORKDIR /app
# Install ca-certs for HTTPS / OIDC
RUN apk add --no-cache ca-certificates
# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy source
COPY . .
# Build statically linked binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -o backend ./cmd/api
# ---------- Runtime stage ----------
FROM gcr.io/distroless/base-debian12
WORKDIR /app
COPY --from=builder /app/backend /app/backend
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app/backend"]

View File

@@ -1,23 +1,22 @@
module go.b0esche.cloud/backend
go 1.25.5
go 1.24.0
require (
github.com/coreos/go-oidc/v3 v3.17.0
github.com/go-chi/chi/v5 v5.2.3
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.6
golang.org/x/crypto v0.37.0
golang.org/x/oauth2 v0.28.0
)
require (
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.6 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/oauth2 v0.28.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)

View File

@@ -1,14 +1,16 @@
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -19,10 +21,13 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
@@ -33,3 +38,5 @@ golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -27,6 +27,7 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.CORS)
r.Use(middleware.RateLimit)
// Health check
@@ -59,49 +60,51 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
})
})
// Auth middleware for protected routes
r.Use(middleware.Auth(jwtManager, db))
// Protected routes (with auth middleware)
r.Route("/", func(r chi.Router) {
r.Use(middleware.Auth(jwtManager, db))
// Org routes
r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) {
listOrgsHandler(w, req, db, jwtManager)
})
r.Post("/orgs", func(w http.ResponseWriter, req *http.Request) {
createOrgHandler(w, req, db, auditLogger, jwtManager)
})
// Org routes
r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) {
listOrgsHandler(w, req, db, jwtManager)
})
r.Post("/orgs", func(w http.ResponseWriter, req *http.Request) {
createOrgHandler(w, req, db, auditLogger, jwtManager)
})
// Org-scoped routes
r.Route("/orgs/{orgId}", func(r chi.Router) {
r.Use(middleware.Org(db, auditLogger))
// Org-scoped routes
r.Route("/orgs/{orgId}", func(r chi.Router) {
r.Use(middleware.Org(db, auditLogger))
// File routes
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files", func(w http.ResponseWriter, req *http.Request) {
listFilesHandler(w, req)
})
r.Route("/files/{fileId}", func(r chi.Router) {
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Get("/view", func(w http.ResponseWriter, req *http.Request) {
viewerHandler(w, req, db, auditLogger)
// File routes
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files", func(w http.ResponseWriter, req *http.Request) {
listFilesHandler(w, req)
})
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Get("/edit", func(w http.ResponseWriter, req *http.Request) {
editorHandler(w, req, db, auditLogger)
r.Route("/files/{fileId}", func(r chi.Router) {
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Get("/view", func(w http.ResponseWriter, req *http.Request) {
viewerHandler(w, req, db, auditLogger)
})
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Get("/edit", func(w http.ResponseWriter, req *http.Request) {
editorHandler(w, req, db, auditLogger)
})
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Post("/annotations", func(w http.ResponseWriter, req *http.Request) {
annotationsHandler(w, req, db, auditLogger)
})
r.Get("/meta", func(w http.ResponseWriter, req *http.Request) {
fileMetaHandler(w, req)
})
})
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Post("/annotations", func(w http.ResponseWriter, req *http.Request) {
annotationsHandler(w, req, db, auditLogger)
r.Get("/activity", func(w http.ResponseWriter, req *http.Request) {
activityHandler(w, req, db)
})
r.Get("/meta", func(w http.ResponseWriter, req *http.Request) {
fileMetaHandler(w, req)
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/members", func(w http.ResponseWriter, req *http.Request) {
listMembersHandler(w, req, db)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Patch("/members/{userId}", func(w http.ResponseWriter, req *http.Request) {
updateMemberRoleHandler(w, req, db, auditLogger)
})
})
r.Get("/activity", func(w http.ResponseWriter, req *http.Request) {
activityHandler(w, req, db)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/members", func(w http.ResponseWriter, req *http.Request) {
listMembersHandler(w, req, db)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Patch("/members/{userId}", func(w http.ResponseWriter, req *http.Request) {
updateMemberRoleHandler(w, req, db, auditLogger)
})
})
}) // Close protected routes
return r
}

View File

@@ -21,6 +21,23 @@ var RequestID = middleware.RequestID
var Logger = middleware.Logger
var Recoverer = middleware.Recoverer
// CORS middleware
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "3600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
// TODO: Implement rate limiter
var RateLimit = func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {