56 Commits

Author SHA1 Message Date
c450b4ae2f Merge pull request #32 from lordmathis/fix/docker-ca-certs
Add ca-certs to docker
2024-12-27 20:14:17 +01:00
eb98f801a1 Add ca-certs to docker 2024-12-27 20:10:19 +01:00
f1f622cda3 Merge pull request #31 from lordmathis/chore/update-readme
Update README config section
2024-12-27 17:01:59 +01:00
5079f3c96d Update README config section 2024-12-27 16:55:56 +01:00
2861b1a719 Merge pull request #30 from lordmathis/chore/update-docs
Update documentation
2024-12-27 16:50:27 +01:00
9bc1b2ecd4 Update documentation 2024-12-27 16:37:31 +01:00
339ab657b8 Merge pull request #29 from lordmathis/chore/rename
Rename app to Lemma
2024-12-20 00:08:18 +01:00
28d2c053a8 Rename missed files 2024-12-20 00:05:23 +01:00
b6b4c01f0e Rename app to Lemma 2024-12-19 23:59:27 +01:00
5598c0861d Merge pull request #28 from lordmathis/feat/logging
Implement structured logging
2024-12-19 23:45:28 +01:00
f0b6aa0d6e Setup test logging 2024-12-19 23:42:19 +01:00
cf2e1809a4 Delete more debug logs 2024-12-19 22:26:29 +01:00
b065938211 Remove too many debug messages 2024-12-19 22:00:42 +01:00
0aa67f5cc2 Ignore env dev file 2024-12-18 22:17:22 +01:00
f6de4fb839 Log the config after loading 2024-12-18 22:16:20 +01:00
7ccd36f0e4 Add logging to handlers 2024-12-17 23:28:01 +01:00
54cefac4b7 Update package comment 2024-12-16 22:39:26 +01:00
032ae1354c Add logging to storage package 2024-12-16 22:38:21 +01:00
03e78c3e6b Harmonize logging 2024-12-16 21:25:17 +01:00
b00f01f033 Add logging to secrets package 2024-12-16 21:25:06 +01:00
51004d980d Implement logging for git package 2024-12-16 21:16:31 +01:00
e7a48fcd27 Add logging to context 2024-12-16 20:59:21 +01:00
d8528d1b42 Merge pull request #27 from LordMathis/dependabot/go_modules/server/go_modules-5a9c29dde4
Bump golang.org/x/crypto from 0.21.0 to 0.31.0 in /server in the go_modules group across 1 directory
2024-12-15 18:48:04 +01:00
3edce8a0b9 Add logging to auth package 2024-12-15 18:03:04 +01:00
d6680d8e03 Update db logging 2024-12-15 17:28:53 +01:00
8dd57bdf0b Use lowercase for log messages 2024-12-15 17:13:40 +01:00
a32e0957ed Add logging to app package 2024-12-15 16:46:29 +01:00
ab00276f0d Add logging to db package 2024-12-15 14:12:39 +01:00
d14eae4de4 Add Logger interface 2024-12-15 13:34:31 +01:00
71df436a93 Simplify logging 2024-12-14 23:59:28 +01:00
1ee8d94789 Add logger to server 2024-12-12 21:06:15 +01:00
9d82b6426c Use slog for logging 2024-12-12 20:53:35 +01:00
dependabot[bot]
6280586aaf Bump golang.org/x/crypto
Bumps the go_modules group with 1 update in the /server directory: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.21.0 to 0.31.0
- [Commits](https://github.com/golang/crypto/compare/v0.21.0...v0.31.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-12 15:49:43 +00:00
ea916c3ecc Initial logging implementation 2024-12-10 22:16:50 +01:00
3d03da221b Merge pull request #26 from LordMathis/feat/cookies
Implement cookie auth
2024-12-09 21:19:35 +01:00
26a208123a Implement cookie auth on frontend 2024-12-09 20:57:56 +01:00
8a62c9e306 Regenerate docs 2024-12-08 17:47:44 +01:00
a7a97caa6a Regenerate api docs 2024-12-08 17:46:59 +01:00
8ebbe1e076 Update api docs 2024-12-08 17:43:50 +01:00
ed6ac312ed Fix failing logout tests 2024-12-08 17:22:39 +01:00
2268ea48f2 Fix session validation 2024-12-08 17:13:34 +01:00
69af630332 Update tests to use test user struct 2024-12-08 15:03:39 +01:00
2e1ccb45d6 Remove extra argument 2024-12-07 23:16:12 +01:00
5633406f5c Update handler integration tests 2024-12-07 23:09:57 +01:00
ad4af2f82d Update auth test 2024-12-07 21:41:37 +01:00
8a4508e29f Update session and cookie managers 2024-12-07 21:19:02 +01:00
de9e9102db Migrate backend auth to cookies 2024-12-05 21:56:35 +01:00
b4528c1561 Merge pull request #25 from LordMathis/chore/readme-badge
Readme badges
2024-12-04 22:09:10 +01:00
1c8e16fc80 Rename workflow 2024-12-04 21:52:21 +01:00
bd90cf3813 Add workflow badges 2024-12-04 21:50:49 +01:00
836e233954 Merge pull request #24 from LordMathis/chore/apidocs
Create swagger api docs
2024-12-04 21:44:35 +01:00
3d36c67c90 Update frontend requests 2024-12-04 21:33:34 +01:00
dc8fc6c225 Working swagger api docs 2024-12-03 22:06:57 +01:00
e413e955c5 Update api docs 2024-12-03 21:50:16 +01:00
c400d81c87 Add initial api doc comments 2024-12-02 22:28:12 +01:00
08e76671d0 Rework app setup 2024-12-02 21:23:47 +01:00
86 changed files with 10029 additions and 1916 deletions

View File

@@ -1,4 +1,4 @@
name: Build, Push, and Release name: Docker Build
on: on:
push: push:
@@ -8,7 +8,7 @@ on:
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
OWNER: lordmathis OWNER: lordmathis
REPO: novamd REPO: lemma
jobs: jobs:
build-and-push-image: build-and-push-image:

1
.gitignore vendored
View File

@@ -157,6 +157,7 @@ go.work.sum
# env file # env file
.env .env
.env.dev
main main
*.db *.db

14
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch Lemma Server",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/server/cmd/server/main.go",
"cwd": "${workspaceFolder}",
"envFile": "${workspaceFolder}/server/.env"
}
]
}

View File

@@ -13,21 +13,23 @@ RUN apt-get update && apt-get install -y gcc musl-dev
COPY server/go.mod server/go.sum ./ COPY server/go.mod server/go.sum ./
RUN go mod download RUN go mod download
COPY server . COPY server .
RUN CGO_ENABLED=1 GOOS=linux go build -o novamd ./cmd/server RUN CGO_ENABLED=1 GOOS=linux go build -o lemma ./cmd/server
# Stage 3: Final stage # Stage 3: Final stage
FROM debian:bookworm-slim FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates
RUN update-ca-certificates
WORKDIR /app WORKDIR /app
COPY --from=backend-builder /app/novamd . COPY --from=backend-builder /app/lemma .
COPY --from=frontend-builder /app/dist ./dist COPY --from=frontend-builder /app/dist ./dist
RUN mkdir -p /app/data RUN mkdir -p /app/data
# Set default environment variables # Set default environment variables
ENV NOVAMD_STATIC_PATH=/app/dist ENV LEMMA_STATIC_PATH=/app/dist
ENV NOVAMD_PORT=8080 ENV LEMMA_PORT=8080
ENV NOVAMD_WORKDIR=/app/data ENV LEMMA_WORKDIR=/app/data
EXPOSE 8080 EXPOSE 8080
CMD ["./novamd"] CMD ["./lemma"]

View File

@@ -1,4 +1,6 @@
# NovaMD # Lemma
![Build](https://github.com/LordMathis/Lemma/actions/workflows/build-and-release.yml/badge.svg) ![Go Tests](https://github.com/LordMathis/Lemma/actions/workflows/go-test.yml/badge.svg)
Yet another markdown editor. Work in progress Yet another markdown editor. Work in progress
@@ -20,26 +22,27 @@ Yet another markdown editor. Work in progress
## Configuration ## Configuration
NovaMD can be configured using environment variables. Here are the available configuration options: Lemma can be configured using environment variables. Here are the available configuration options:
### Required Environment Variables ### Required Environment Variables
- `NOVAMD_ADMIN_EMAIL`: Email address for the admin account - `LEMMA_ADMIN_EMAIL`: Email address for the admin account
- `NOVAMD_ADMIN_PASSWORD`: Password for the admin account - `LEMMA_ADMIN_PASSWORD`: Password for the admin account
- `NOVAMD_ENCRYPTION_KEY`: Base64-encoded 32-byte key used for encrypting sensitive data - `LEMMA_ENCRYPTION_KEY`: Base64-encoded 32-byte key used for encrypting sensitive data
### Optional Environment Variables ### Optional Environment Variables
- `NOVAMD_ENV`: Set to "development" to enable development mode - `LEMMA_ENV`: Set to "development" to enable development mode
- `NOVAMD_DB_PATH`: Path to the SQLite database file (default: "./novamd.db") - `LEMMA_DB_PATH`: Path to the SQLite database file (default: "./lemma.db")
- `NOVAMD_WORKDIR`: Working directory for application data (default: "./data") - `LEMMA_WORKDIR`: Working directory for application data (default: "./data")
- `NOVAMD_STATIC_PATH`: Path to static files (default: "../app/dist") - `LEMMA_STATIC_PATH`: Path to static files (default: "../app/dist")
- `NOVAMD_PORT`: Port to run the server on (default: "8080") - `LEMMA_PORT`: Port to run the server on (default: "8080")
- `NOVAMD_APP_URL`: Full URL where the application is hosted - `LEMMA_DOMAIN`: Domain name where the application is hosted for cookie authentication
- `NOVAMD_CORS_ORIGINS`: Comma-separated list of allowed CORS origins - `LEMMA_CORS_ORIGINS`: Comma-separated list of allowed CORS origins
- `NOVAMD_JWT_SIGNING_KEY`: Key used for signing JWT tokens (autogenerated if not set) - `LEMMA_JWT_SIGNING_KEY`: Key used for signing JWT tokens
- `NOVAMD_RATE_LIMIT_REQUESTS`: Number of allowed requests per window (default: 100) - `LEMMA_LOG_LEVEL`: Logging level (defaults to DEBUG in development mode, INFO in production)
- `NOVAMD_RATE_LIMIT_WINDOW`: Duration of the rate limit window (default: 15m) - `LEMMA_RATE_LIMIT_REQUESTS`: Number of allowed requests per window (default: 100)
- `LEMMA_RATE_LIMIT_WINDOW`: Duration of the rate limit window (default: 15m)
### Generating Encryption Keys ### Generating Encryption Keys
@@ -86,10 +89,10 @@ Store the generated key securely - it will be needed to decrypt any data encrypt
2. Build the backend: 2. Build the backend:
``` ```
cd server cd server
go build -o novamd ./cmd/server go build -o lemma ./cmd/server
``` ```
3. Set the `NOVAMD_STATIC_PATH` environment variable to point to the frontend build directory 3. Set the `LEMMA_STATIC_PATH` environment variable to point to the frontend build directory
4. Run the `novamd` executable 4. Run the `lemma` executable
## Docker Support ## Docker Support
@@ -97,11 +100,11 @@ A Dockerfile is provided for easy deployment. To build and run the Docker image:
1. Build the image: 1. Build the image:
``` ```
docker build -t novamd . docker build -t lemma .
``` ```
2. Run the container: 2. Run the container:
``` ```
docker run -p 8080:8080 -v /path/to/data:/app/data novamd docker run -p 8080:8080 -v /path/to/data:/app/data lemma
``` ```
## Upgrading ## Upgrading

10
app/package-lock.json generated
View File

@@ -1,11 +1,11 @@
{ {
"name": "novamd-frontend", "name": "lemma-frontend",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "novamd-frontend", "name": "lemma-frontend",
"version": "0.1.0", "version": "0.1.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -4170,9 +4170,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.7", "version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {

View File

@@ -1,5 +1,5 @@
{ {
"name": "novamd-frontend", "name": "lemma-frontend",
"version": "0.1.0", "version": "0.1.0",
"description": "Yet another markdown editor", "description": "Yet another markdown editor",
"type": "module", "type": "module",
@@ -10,7 +10,7 @@
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/LordMathis/NovaMD.git" "url": "git+https://github.com/LordMathis/Lemma.git"
}, },
"keywords": [ "keywords": [
"markdown", "markdown",
@@ -19,9 +19,9 @@
"author": "Matúš Námešný", "author": "Matúš Námešný",
"license": "Apache-2.0", "license": "Apache-2.0",
"bugs": { "bugs": {
"url": "https://github.com/LordMathis/NovaMD/issues" "url": "https://github.com/LordMathis/Lemma/issues"
}, },
"homepage": "https://github.com/LordMathis/NovaMD#readme", "homepage": "https://github.com/LordMathis/Lemma#readme",
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.6.2", "@codemirror/commands": "^6.6.2",
"@codemirror/lang-markdown": "^6.2.5", "@codemirror/lang-markdown": "^6.2.5",

View File

@@ -29,7 +29,7 @@ const LoginPage = () => {
return ( return (
<Container size={420} my={40}> <Container size={420} my={40}>
<Title ta="center">Welcome to NovaMD</Title> <Title ta="center">Welcome to Lemma</Title>
<Text c="dimmed" size="sm" ta="center" mt={5}> <Text c="dimmed" size="sm" ta="center" mt={5}>
Please sign in to continue Please sign in to continue
</Text> </Text>

View File

@@ -8,7 +8,7 @@ const Header = () => {
return ( return (
<Group justify="space-between" h={60} px="md"> <Group justify="space-between" h={60} px="md">
<Text fw={700} size="lg"> <Text fw={700} size="lg">
NovaMD Lemma
</Text> </Text>
<Group> <Group>
<WorkspaceSwitcher /> <WorkspaceSwitcher />

View File

@@ -19,16 +19,10 @@ export const AuthProvider = ({ children }) => {
useEffect(() => { useEffect(() => {
const initializeAuth = async () => { const initializeAuth = async () => {
try { try {
const storedToken = localStorage.getItem('accessToken'); const userData = await authApi.getCurrentUser();
if (storedToken) { setUser(userData);
authApi.setAuthToken(storedToken);
const userData = await authApi.getCurrentUser();
setUser(userData);
}
} catch (error) { } catch (error) {
console.error('Failed to initialize auth:', error); console.error('Failed to initialize auth:', error);
localStorage.removeItem('accessToken');
authApi.clearAuthToken();
} finally { } finally {
setLoading(false); setLoading(false);
setInitialized(true); setInitialized(true);
@@ -40,12 +34,7 @@ export const AuthProvider = ({ children }) => {
const login = useCallback(async (email, password) => { const login = useCallback(async (email, password) => {
try { try {
const { accessToken, user: userData } = await authApi.login( const { user: userData } = await authApi.login(email, password);
email,
password
);
localStorage.setItem('accessToken', accessToken);
authApi.setAuthToken(accessToken);
setUser(userData); setUser(userData);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
@@ -70,18 +59,17 @@ export const AuthProvider = ({ children }) => {
} catch (error) { } catch (error) {
console.error('Logout failed:', error); console.error('Logout failed:', error);
} finally { } finally {
localStorage.removeItem('accessToken');
authApi.clearAuthToken();
setUser(null); setUser(null);
} }
}, []); }, []);
const refreshToken = useCallback(async () => { const refreshToken = useCallback(async () => {
try { try {
const { accessToken } = await authApi.refreshToken(); const success = await authApi.refreshToken();
localStorage.setItem('accessToken', accessToken); if (!success) {
authApi.setAuthToken(accessToken); await logout();
return true; }
return success;
} catch (error) { } catch (error) {
console.error('Token refresh failed:', error); console.error('Token refresh failed:', error);
await logout(); await logout();

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NovaMD</title> <title>Lemma</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -47,17 +47,16 @@ export const saveFileContent = async (workspaceName, filePath, content) => {
body: content, body: content,
} }
); );
return response.text(); return response.json();
}; };
export const deleteFile = async (workspaceName, filePath) => { export const deleteFile = async (workspaceName, filePath) => {
const response = await apiCall( await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`, `${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`,
{ {
method: 'DELETE', method: 'DELETE',
} }
); );
return response.text();
}; };
export const getWorkspace = async (workspaceName) => { export const getWorkspace = async (workspaceName) => {
@@ -119,17 +118,13 @@ export const lookupFileByName = async (workspaceName, filename) => {
}; };
export const updateLastOpenedFile = async (workspaceName, filePath) => { export const updateLastOpenedFile = async (workspaceName, filePath) => {
const response = await apiCall( await apiCall(`${API_BASE_URL}/workspaces/${workspaceName}/files/last`, {
`${API_BASE_URL}/workspaces/${workspaceName}/files/last`, method: 'PUT',
{ headers: {
method: 'PUT', 'Content-Type': 'application/json',
headers: { },
'Content-Type': 'application/json', body: JSON.stringify({ filePath }),
}, });
body: JSON.stringify({ filePath }),
}
);
return response.json();
}; };
export const getLastOpenedFile = async (workspaceName) => { export const getLastOpenedFile = async (workspaceName) => {

View File

@@ -1,40 +1,32 @@
import { API_BASE_URL } from '../utils/constants'; import { API_BASE_URL } from '../utils/constants';
let authToken = null;
export const setAuthToken = (token) => {
authToken = token;
};
export const clearAuthToken = () => {
authToken = null;
};
export const getAuthHeaders = () => {
const headers = {
'Content-Type': 'application/json',
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
return headers;
};
// Update the existing apiCall function to include auth headers
export const apiCall = async (url, options = {}) => { export const apiCall = async (url, options = {}) => {
try { try {
const headers = { const headers = {
...getAuthHeaders(), 'Content-Type': 'application/json',
...options.headers, ...options.headers,
}; };
if (options.method && options.method !== 'GET') {
const csrfToken = document.cookie
.split('; ')
.find((row) => row.startsWith('csrf_token='))
?.split('=')[1];
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
}
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
headers, headers,
credentials: 'include',
}); });
if (response.status === 429) {
throw new Error('Rate limit exceeded');
}
// Handle 401 responses // Handle 401 responses
if (response.status === 401) { if (response.status === 401) {
const isRefreshEndpoint = url.endsWith('/auth/refresh'); const isRefreshEndpoint = url.endsWith('/auth/refresh');
@@ -42,20 +34,26 @@ export const apiCall = async (url, options = {}) => {
// Attempt token refresh and retry the request // Attempt token refresh and retry the request
const refreshSuccess = await refreshToken(); const refreshSuccess = await refreshToken();
if (refreshSuccess) { if (refreshSuccess) {
// Retry the original request with the new token // Retry the original request
return apiCall(url, options); return apiCall(url, options);
} }
} }
throw new Error('Authentication failed'); throw new Error('Authentication failed');
} }
if (!response.ok) { // Handle other error responses
if (!response.ok && response.status !== 204) {
const errorData = await response.json().catch(() => null); const errorData = await response.json().catch(() => null);
throw new Error( throw new Error(
errorData?.message || `HTTP error! status: ${response.status}` errorData?.message || `HTTP error! status: ${response.status}`
); );
} }
// Return null for 204 responses
if (response.status === 204) {
return null;
}
return response; return response;
} catch (error) { } catch (error) {
console.error(`API call failed: ${error.message}`); console.error(`API call failed: ${error.message}`);
@@ -69,26 +67,29 @@ export const login = async (email, password) => {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}); });
return response.json();
const data = await response.json();
// No need to store tokens as they're in cookies now
return data;
}; };
export const logout = async () => { export const logout = async () => {
const sessionId = localStorage.getItem('sessionId');
await apiCall(`${API_BASE_URL}/auth/logout`, { await apiCall(`${API_BASE_URL}/auth/logout`, {
method: 'POST', method: 'POST',
headers: {
'X-Session-ID': sessionId,
},
}); });
return;
}; };
export const refreshToken = async () => { export const refreshToken = async () => {
const refreshToken = localStorage.getItem('refreshToken'); try {
const response = await apiCall(`${API_BASE_URL}/auth/refresh`, { const response = await apiCall(`${API_BASE_URL}/auth/refresh`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ refreshToken }), });
}); return response.status === 200;
return response.json(); } catch (error) {
console.error('Token refresh failed:', error);
return false;
}
}; };
export const getCurrentUser = async () => { export const getCurrentUser = async () => {

View File

@@ -47,7 +47,7 @@ export const DEFAULT_WORKSPACE = {
export const DEFAULT_FILE = { export const DEFAULT_FILE = {
name: 'New File.md', name: 'New File.md',
path: 'New File.md', path: 'New File.md',
content: '# Welcome to NovaMD\n\nStart editing here!', content: '# Welcome to Lemma\n\nStart editing here!',
}; };
export const MARKDOWN_REGEX = { export const MARKDOWN_REGEX = {

View File

@@ -4,25 +4,40 @@ package main
import ( import (
"log" "log"
"novamd/internal/app" "lemma/internal/app"
"novamd/internal/config" "lemma/internal/logging"
) )
// @title Lemma API
// @version 1.0
// @description This is the API for Lemma markdown note taking app.
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @BasePath /api/v1
// @SecurityDefinitions.ApiKey CookieAuth
// @In cookie
// @Name access_token
func main() { func main() {
// Load configuration // Load configuration
cfg, err := config.Load() cfg, err := app.LoadConfig()
if err != nil { if err != nil {
log.Fatal("Failed to load configuration:", err) log.Fatal("Failed to load configuration:", err)
} }
// Setup logging
logging.Setup(cfg.LogLevel)
logging.Debug("Configuration loaded", "config", cfg.Redact())
// Initialize and start server // Initialize and start server
server, err := app.NewServer(cfg) options, err := app.DefaultOptions(cfg)
if err != nil { if err != nil {
log.Fatal("Failed to initialize server:", err) log.Fatal("Failed to initialize server options:", err)
} }
server := app.NewServer(options)
defer func() { defer func() {
if err := server.Close(); err != nil { if err := server.Close(); err != nil {
log.Println("Error closing server:", err) logging.Error("Failed to close server:", err)
} }
}() }()

1790
server/docs/docs.go Normal file

File diff suppressed because it is too large Load Diff

1765
server/docs/swagger.json Normal file

File diff suppressed because it is too large Load Diff

1143
server/docs/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

1164
server/documentation.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,50 +1,34 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
# Function to generate anchor from package path
generate_anchor() { generate_anchor() {
echo "$1" | tr '/' '-' echo "$1" | tr '/' '-'
} }
# Create documentation file echo "# Lemma Package Documentation"
echo "# NovaMD Package Documentation echo ""
echo "Generated documentation for all packages in the Lemma project."
echo ""
echo "## Table of Contents"
Generated documentation for all packages in the NovaMD project.
## Table of Contents
" > documentation.md
# Find all directories containing .go files (excluding test files)
# Sort them for consistent output
PACKAGES=$(find . -type f -name "*.go" ! -name "*_test.go" -exec dirname {} \; | sort -u | grep -v "/\.") PACKAGES=$(find . -type f -name "*.go" ! -name "*_test.go" -exec dirname {} \; | sort -u | grep -v "/\.")
# Generate table of contents
for PKG in $PACKAGES; do for PKG in $PACKAGES; do
# Strip leading ./
PKG_PATH=${PKG#./} PKG_PATH=${PKG#./}
# Skip if empty
[ -z "$PKG_PATH" ] && continue [ -z "$PKG_PATH" ] && continue
ANCHOR=$(generate_anchor "$PKG_PATH") ANCHOR=$(generate_anchor "$PKG_PATH")
echo "- [$PKG_PATH](#$ANCHOR)" >> documentation.md echo "- [$PKG_PATH](#$ANCHOR)"
done done
echo "" >> documentation.md echo ""
# Generate documentation for each package
for PKG in $PACKAGES; do for PKG in $PACKAGES; do
# Strip leading ./
PKG_PATH=${PKG#./} PKG_PATH=${PKG#./}
# Skip if empty
[ -z "$PKG_PATH" ] && continue [ -z "$PKG_PATH" ] && continue
echo "## $PKG_PATH"
echo "## $PKG_PATH" >> documentation.md echo ""
echo "" >> documentation.md echo '```go'
echo '```go' >> documentation.md go doc -all "./$PKG_PATH" | cat
go doc -all "./$PKG_PATH" >> documentation.md echo '```'
echo '```' >> documentation.md echo ""
echo "" >> documentation.md
done done
echo "Documentation generated in documentation.md"

View File

@@ -1,4 +1,4 @@
module novamd module lemma
go 1.23.1 go 1.23.1
@@ -12,12 +12,15 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.23 github.com/mattn/go-sqlite3 v1.14.23
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.4
github.com/unrolled/secure v1.17.0 github.com/unrolled/secure v1.17.0
golang.org/x/crypto v0.21.0 golang.org/x/crypto v0.31.0
) )
require ( require (
dario.cat/mergo v1.0.0 // indirect dario.cat/mergo v1.0.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v1.0.0 // indirect github.com/ProtonMail/go-crypto v1.0.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -28,22 +31,31 @@ require (
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect
github.com/go-openapi/spec v0.20.6 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect github.com/skeema/knownhosts v1.2.2 // indirect
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/mod v0.12.0 // indirect golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.23.0 // indirect golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.18.0 // indirect golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/sys v0.28.0 // indirect
golang.org/x/tools v0.13.0 // indirect golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@@ -1,5 +1,7 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
@@ -15,6 +17,7 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -42,6 +45,16 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ=
github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -60,6 +73,8 @@ 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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -71,8 +86,13 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
@@ -90,9 +110,17 @@ github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L
github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe h1:K8pHPVoTgxFJt1lXuIzzOX7zZhZFldJQK/CgKx9BFIc=
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
@@ -103,26 +131,27 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -136,15 +165,15 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -152,22 +181,27 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,97 +0,0 @@
// Package api contains the API routes for the application. It sets up the routes for the public and protected endpoints, as well as the admin-only routes.
package api
import (
"novamd/internal/auth"
"novamd/internal/context"
"novamd/internal/db"
"novamd/internal/handlers"
"novamd/internal/storage"
"github.com/go-chi/chi/v5"
)
// SetupRoutes configures the API routes
func SetupRoutes(r chi.Router, db db.Database, s storage.Manager, authMiddleware *auth.Middleware, sessionService *auth.SessionService) {
handler := &handlers.Handler{
DB: db,
Storage: s,
}
// Public routes (no authentication required)
r.Group(func(r chi.Router) {
r.Post("/auth/login", handler.Login(sessionService))
r.Post("/auth/refresh", handler.RefreshToken(sessionService))
})
// Protected routes (authentication required)
r.Group(func(r chi.Router) {
// Apply authentication middleware to all routes in this group
r.Use(authMiddleware.Authenticate)
r.Use(context.WithUserContextMiddleware)
// Auth routes
r.Post("/auth/logout", handler.Logout(sessionService))
r.Get("/auth/me", handler.GetCurrentUser())
// User profile routes
r.Put("/profile", handler.UpdateProfile())
r.Delete("/profile", handler.DeleteAccount())
// Admin-only routes
r.Route("/admin", func(r chi.Router) {
r.Use(authMiddleware.RequireRole("admin"))
// User management
r.Route("/users", func(r chi.Router) {
r.Get("/", handler.AdminListUsers())
r.Post("/", handler.AdminCreateUser())
r.Get("/{userId}", handler.AdminGetUser())
r.Put("/{userId}", handler.AdminUpdateUser())
r.Delete("/{userId}", handler.AdminDeleteUser())
})
// Workspace management
r.Route("/workspaces", func(r chi.Router) {
r.Get("/", handler.AdminListWorkspaces())
})
// System stats
r.Get("/stats", handler.AdminGetSystemStats())
})
// Workspace routes
r.Route("/workspaces", func(r chi.Router) {
r.Get("/", handler.ListWorkspaces())
r.Post("/", handler.CreateWorkspace())
r.Get("/last", handler.GetLastWorkspaceName())
r.Put("/last", handler.UpdateLastWorkspaceName())
// Single workspace routes
r.Route("/{workspaceName}", func(r chi.Router) {
r.Use(context.WithWorkspaceContextMiddleware(db))
r.Use(authMiddleware.RequireWorkspaceAccess)
r.Get("/", handler.GetWorkspace())
r.Put("/", handler.UpdateWorkspace())
r.Delete("/", handler.DeleteWorkspace())
// File routes
r.Route("/files", func(r chi.Router) {
r.Get("/", handler.ListFiles())
r.Get("/last", handler.GetLastOpenedFile())
r.Put("/last", handler.UpdateLastOpenedFile())
r.Get("/lookup", handler.LookupFileByName())
r.Post("/*", handler.SaveFile())
r.Get("/*", handler.GetFileContent())
r.Delete("/*", handler.DeleteFile())
})
// Git routes
r.Route("/git", func(r chi.Router) {
r.Post("/commit", handler.StageCommitAndPush())
r.Post("/pull", handler.PullChanges())
})
})
})
})
}

View File

@@ -1,223 +0,0 @@
// Package app provides application-level functionality for initializing and running the server
package app
import (
"database/sql"
"fmt"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"github.com/unrolled/secure"
"golang.org/x/crypto/bcrypt"
"novamd/internal/api"
"novamd/internal/auth"
"novamd/internal/config"
"novamd/internal/db"
"novamd/internal/handlers"
"novamd/internal/models"
"novamd/internal/secrets"
"novamd/internal/storage"
)
// Server represents the HTTP server and its dependencies
type Server struct {
router *chi.Mux
config *config.Config
db db.Database
storage storage.Manager
}
// NewServer initializes a new server instance with all dependencies
func NewServer(cfg *config.Config) (*Server, error) {
// Initialize secrets service
secretsService, err := secrets.NewService(cfg.EncryptionKey)
if err != nil {
return nil, fmt.Errorf("failed to initialize secrets service: %w", err)
}
// Initialize database
database, err := initDatabase(cfg, secretsService)
if err != nil {
return nil, fmt.Errorf("failed to initialize database: %w", err)
}
// Initialize filesystem
storageManager := storage.NewService(cfg.WorkDir)
// Setup admin user
err = setupAdminUser(database, storageManager, cfg)
if err != nil {
return nil, fmt.Errorf("failed to setup admin user: %w", err)
}
// Initialize router
router := initRouter(cfg)
return &Server{
router: router,
config: cfg,
db: database,
storage: storageManager,
}, nil
}
// Start configures and starts the HTTP server
func (s *Server) Start() error {
// Set up authentication
jwtManager, sessionService, err := s.setupAuth()
if err != nil {
return fmt.Errorf("failed to setup authentication: %w", err)
}
// Set up routes
s.setupRoutes(jwtManager, sessionService)
// Start server
addr := ":" + s.config.Port
log.Printf("Server starting on port %s", s.config.Port)
return http.ListenAndServe(addr, s.router)
}
// Close handles graceful shutdown of server dependencies
func (s *Server) Close() error {
return s.db.Close()
}
// initDatabase initializes and migrates the database
func initDatabase(cfg *config.Config, secretsService secrets.Service) (db.Database, error) {
database, err := db.Init(cfg.DBPath, secretsService)
if err != nil {
return nil, fmt.Errorf("failed to initialize database: %w", err)
}
if err := database.Migrate(); err != nil {
return nil, fmt.Errorf("failed to apply database migrations: %w", err)
}
return database, nil
}
// initRouter creates and configures the chi router with middleware
func initRouter(cfg *config.Config) *chi.Mux {
r := chi.NewRouter()
// Basic middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Timeout(30 * time.Second))
// Security headers
r.Use(secure.New(secure.Options{
SSLRedirect: false, // Let proxy handle HTTPS
SSLProxyHeaders: map[string]string{"X-Forwarded-Proto": "https"},
IsDevelopment: cfg.IsDevelopment,
}).Handler)
// CORS if origins are configured
if len(cfg.CORSOrigins) > 0 {
r.Use(cors.Handler(cors.Options{
AllowedOrigins: cfg.CORSOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Requested-With"},
AllowCredentials: true,
MaxAge: 300,
}))
}
return r
}
// setupAuth initializes JWT and session services
func (s *Server) setupAuth() (auth.JWTManager, *auth.SessionService, error) {
// Get or generate JWT signing key
signingKey := s.config.JWTSigningKey
if signingKey == "" {
var err error
signingKey, err = s.db.EnsureJWTSecret()
if err != nil {
return nil, nil, fmt.Errorf("failed to ensure JWT secret: %w", err)
}
}
// Initialize JWT service
jwtManager, err := auth.NewJWTService(auth.JWTConfig{
SigningKey: signingKey,
AccessTokenExpiry: 15 * time.Minute,
RefreshTokenExpiry: 7 * 24 * time.Hour,
})
if err != nil {
return nil, nil, fmt.Errorf("failed to initialize JWT service: %w", err)
}
// Initialize session service
sessionService := auth.NewSessionService(s.db, jwtManager)
return jwtManager, sessionService, nil
}
// setupRoutes configures all application routes
func (s *Server) setupRoutes(jwtManager auth.JWTManager, sessionService *auth.SessionService) {
// Initialize auth middleware
authMiddleware := auth.NewMiddleware(jwtManager)
// Set up API routes
s.router.Route("/api/v1", func(r chi.Router) {
r.Use(httprate.LimitByIP(s.config.RateLimitRequests, s.config.RateLimitWindow))
api.SetupRoutes(r, s.db, s.storage, authMiddleware, sessionService)
})
// Handle all other routes with static file server
s.router.Get("/*", handlers.NewStaticHandler(s.config.StaticPath).ServeHTTP)
}
func setupAdminUser(db db.Database, w storage.WorkspaceManager, cfg *config.Config) error {
adminEmail := cfg.AdminEmail
adminPassword := cfg.AdminPassword
// Check if admin user exists
adminUser, err := db.GetUserByEmail(adminEmail)
if adminUser != nil {
return nil // Admin user already exists
} else if err != sql.ErrNoRows {
return err
}
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
// Create admin user
adminUser = &models.User{
Email: adminEmail,
DisplayName: "Admin",
PasswordHash: string(hashedPassword),
Role: models.RoleAdmin,
}
createdUser, err := db.CreateUser(adminUser)
if err != nil {
return fmt.Errorf("failed to create admin user: %w", err)
}
// Initialize workspace directory
err = w.InitializeUserWorkspace(createdUser.ID, createdUser.LastWorkspaceID)
if err != nil {
return fmt.Errorf("failed to initialize admin workspace: %w", err)
}
log.Printf("Created admin user with ID: %d and default workspace with ID: %d", createdUser.ID, createdUser.LastWorkspaceID)
return nil
}

View File

@@ -0,0 +1,136 @@
package app
import (
"fmt"
"lemma/internal/logging"
"lemma/internal/secrets"
"os"
"strconv"
"strings"
"time"
)
// Config holds the configuration for the application
type Config struct {
DBPath string
WorkDir string
StaticPath string
Port string
Domain string
CORSOrigins []string
AdminEmail string
AdminPassword string
EncryptionKey string
JWTSigningKey string
RateLimitRequests int
RateLimitWindow time.Duration
IsDevelopment bool
LogLevel logging.LogLevel
}
// DefaultConfig returns a new Config instance with default values
func DefaultConfig() *Config {
return &Config{
DBPath: "./lemma.db",
WorkDir: "./data",
StaticPath: "../app/dist",
Port: "8080",
RateLimitRequests: 100,
RateLimitWindow: time.Minute * 15,
IsDevelopment: false,
}
}
// validate checks if the configuration is valid
func (c *Config) validate() error {
if c.AdminEmail == "" || c.AdminPassword == "" {
return fmt.Errorf("LEMMA_ADMIN_EMAIL and LEMMA_ADMIN_PASSWORD must be set")
}
// Validate encryption key
if err := secrets.ValidateKey(c.EncryptionKey); err != nil {
return fmt.Errorf("invalid LEMMA_ENCRYPTION_KEY: %w", err)
}
return nil
}
// Redact redacts sensitive fields from a Config instance
func (c *Config) Redact() *Config {
redacted := *c
redacted.AdminPassword = "[REDACTED]"
redacted.AdminEmail = "[REDACTED]"
redacted.EncryptionKey = "[REDACTED]"
redacted.JWTSigningKey = "[REDACTED]"
return &redacted
}
// LoadConfig creates a new Config instance with values from environment variables
func LoadConfig() (*Config, error) {
config := DefaultConfig()
if env := os.Getenv("LEMMA_ENV"); env != "" {
config.IsDevelopment = env == "development"
}
if dbPath := os.Getenv("LEMMA_DB_PATH"); dbPath != "" {
config.DBPath = dbPath
}
if workDir := os.Getenv("LEMMA_WORKDIR"); workDir != "" {
config.WorkDir = workDir
}
if staticPath := os.Getenv("LEMMA_STATIC_PATH"); staticPath != "" {
config.StaticPath = staticPath
}
if port := os.Getenv("LEMMA_PORT"); port != "" {
config.Port = port
}
if domain := os.Getenv("LEMMA_DOMAIN"); domain != "" {
config.Domain = domain
}
if corsOrigins := os.Getenv("LEMMA_CORS_ORIGINS"); corsOrigins != "" {
config.CORSOrigins = strings.Split(corsOrigins, ",")
}
config.AdminEmail = os.Getenv("LEMMA_ADMIN_EMAIL")
config.AdminPassword = os.Getenv("LEMMA_ADMIN_PASSWORD")
config.EncryptionKey = os.Getenv("LEMMA_ENCRYPTION_KEY")
config.JWTSigningKey = os.Getenv("LEMMA_JWT_SIGNING_KEY")
// Configure rate limiting
if reqStr := os.Getenv("LEMMA_RATE_LIMIT_REQUESTS"); reqStr != "" {
parsed, err := strconv.Atoi(reqStr)
if err == nil {
config.RateLimitRequests = parsed
}
}
if windowStr := os.Getenv("LEMMA_RATE_LIMIT_WINDOW"); windowStr != "" {
parsed, err := time.ParseDuration(windowStr)
if err == nil {
config.RateLimitWindow = parsed
}
}
// Configure log level, if isDevelopment is set, default to debug
if logLevel := os.Getenv("LEMMA_LOG_LEVEL"); logLevel != "" {
parsed := logging.ParseLogLevel(logLevel)
config.LogLevel = parsed
} else if config.IsDevelopment {
config.LogLevel = logging.DEBUG
} else {
config.LogLevel = logging.INFO
}
// Validate all settings
if err := config.validate(); err != nil {
return nil, err
}
return config, nil
}

View File

@@ -1,22 +1,23 @@
package config_test package app_test
import ( import (
"lemma/internal/app"
"os" "os"
"testing" "testing"
"time" "time"
"novamd/internal/config" _ "lemma/internal/testenv"
) )
func TestDefaultConfig(t *testing.T) { func TestDefaultConfig(t *testing.T) {
cfg := config.DefaultConfig() cfg := app.DefaultConfig()
tests := []struct { tests := []struct {
name string name string
got interface{} got interface{}
expected interface{} expected interface{}
}{ }{
{"DBPath", cfg.DBPath, "./novamd.db"}, {"DBPath", cfg.DBPath, "./lemma.db"},
{"WorkDir", cfg.WorkDir, "./data"}, {"WorkDir", cfg.WorkDir, "./data"},
{"StaticPath", cfg.StaticPath, "../app/dist"}, {"StaticPath", cfg.StaticPath, "../app/dist"},
{"Port", cfg.Port, "8080"}, {"Port", cfg.Port, "8080"},
@@ -45,19 +46,19 @@ func TestLoad(t *testing.T) {
// Helper function to reset environment variables // Helper function to reset environment variables
cleanup := func() { cleanup := func() {
envVars := []string{ envVars := []string{
"NOVAMD_ENV", "LEMMA_ENV",
"NOVAMD_DB_PATH", "LEMMA_DB_PATH",
"NOVAMD_WORKDIR", "LEMMA_WORKDIR",
"NOVAMD_STATIC_PATH", "LEMMA_STATIC_PATH",
"NOVAMD_PORT", "LEMMA_PORT",
"NOVAMD_APP_URL", "LEMMA_DOMAIN",
"NOVAMD_CORS_ORIGINS", "LEMMA_CORS_ORIGINS",
"NOVAMD_ADMIN_EMAIL", "LEMMA_ADMIN_EMAIL",
"NOVAMD_ADMIN_PASSWORD", "LEMMA_ADMIN_PASSWORD",
"NOVAMD_ENCRYPTION_KEY", "LEMMA_ENCRYPTION_KEY",
"NOVAMD_JWT_SIGNING_KEY", "LEMMA_JWT_SIGNING_KEY",
"NOVAMD_RATE_LIMIT_REQUESTS", "LEMMA_RATE_LIMIT_REQUESTS",
"NOVAMD_RATE_LIMIT_WINDOW", "LEMMA_RATE_LIMIT_WINDOW",
} }
for _, env := range envVars { for _, env := range envVars {
if err := os.Unsetenv(env); err != nil { if err := os.Unsetenv(env); err != nil {
@@ -71,17 +72,17 @@ func TestLoad(t *testing.T) {
defer cleanup() defer cleanup()
// Set required env vars // Set required env vars
setEnv(t, "NOVAMD_ADMIN_EMAIL", "admin@example.com") setEnv(t, "LEMMA_ADMIN_EMAIL", "admin@example.com")
setEnv(t, "NOVAMD_ADMIN_PASSWORD", "password123") setEnv(t, "LEMMA_ADMIN_PASSWORD", "password123")
setEnv(t, "NOVAMD_ENCRYPTION_KEY", "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=") // 32 bytes base64 encoded setEnv(t, "LEMMA_ENCRYPTION_KEY", "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=") // 32 bytes base64 encoded
cfg, err := config.Load() cfg, err := app.LoadConfig()
if err != nil { if err != nil {
t.Fatalf("Load() error = %v", err) t.Fatalf("Load() error = %v", err)
} }
if cfg.DBPath != "./novamd.db" { if cfg.DBPath != "./lemma.db" {
t.Errorf("default DBPath = %v, want %v", cfg.DBPath, "./novamd.db") t.Errorf("default DBPath = %v, want %v", cfg.DBPath, "./lemma.db")
} }
}) })
@@ -91,26 +92,26 @@ func TestLoad(t *testing.T) {
// Set all environment variables // Set all environment variables
envs := map[string]string{ envs := map[string]string{
"NOVAMD_ENV": "development", "LEMMA_ENV": "development",
"NOVAMD_DB_PATH": "/custom/db/path.db", "LEMMA_DB_PATH": "/custom/db/path.db",
"NOVAMD_WORKDIR": "/custom/work/dir", "LEMMA_WORKDIR": "/custom/work/dir",
"NOVAMD_STATIC_PATH": "/custom/static/path", "LEMMA_STATIC_PATH": "/custom/static/path",
"NOVAMD_PORT": "3000", "LEMMA_PORT": "3000",
"NOVAMD_APP_URL": "http://localhost:3000", "LEMMA_ROOT_URL": "http://localhost:3000",
"NOVAMD_CORS_ORIGINS": "http://localhost:3000,http://localhost:3001", "LEMMA_CORS_ORIGINS": "http://localhost:3000,http://localhost:3001",
"NOVAMD_ADMIN_EMAIL": "admin@example.com", "LEMMA_ADMIN_EMAIL": "admin@example.com",
"NOVAMD_ADMIN_PASSWORD": "password123", "LEMMA_ADMIN_PASSWORD": "password123",
"NOVAMD_ENCRYPTION_KEY": "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=", "LEMMA_ENCRYPTION_KEY": "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=",
"NOVAMD_JWT_SIGNING_KEY": "secret-key", "LEMMA_JWT_SIGNING_KEY": "secret-key",
"NOVAMD_RATE_LIMIT_REQUESTS": "200", "LEMMA_RATE_LIMIT_REQUESTS": "200",
"NOVAMD_RATE_LIMIT_WINDOW": "30m", "LEMMA_RATE_LIMIT_WINDOW": "30m",
} }
for k, v := range envs { for k, v := range envs {
setEnv(t, k, v) setEnv(t, k, v)
} }
cfg, err := config.Load() cfg, err := app.LoadConfig()
if err != nil { if err != nil {
t.Fatalf("Load() error = %v", err) t.Fatalf("Load() error = %v", err)
} }
@@ -125,7 +126,6 @@ func TestLoad(t *testing.T) {
{"WorkDir", cfg.WorkDir, "/custom/work/dir"}, {"WorkDir", cfg.WorkDir, "/custom/work/dir"},
{"StaticPath", cfg.StaticPath, "/custom/static/path"}, {"StaticPath", cfg.StaticPath, "/custom/static/path"},
{"Port", cfg.Port, "3000"}, {"Port", cfg.Port, "3000"},
{"AppURL", cfg.AppURL, "http://localhost:3000"},
{"AdminEmail", cfg.AdminEmail, "admin@example.com"}, {"AdminEmail", cfg.AdminEmail, "admin@example.com"},
{"AdminPassword", cfg.AdminPassword, "password123"}, {"AdminPassword", cfg.AdminPassword, "password123"},
{"JWTSigningKey", cfg.JWTSigningKey, "secret-key"}, {"JWTSigningKey", cfg.JWTSigningKey, "secret-key"},
@@ -163,45 +163,45 @@ func TestLoad(t *testing.T) {
name: "missing admin email", name: "missing admin email",
setupEnv: func(t *testing.T) { setupEnv: func(t *testing.T) {
cleanup() cleanup()
setEnv(t, "NOVAMD_ADMIN_PASSWORD", "password123") setEnv(t, "LEMMA_ADMIN_PASSWORD", "password123")
setEnv(t, "NOVAMD_ENCRYPTION_KEY", "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=") setEnv(t, "LEMMA_ENCRYPTION_KEY", "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=")
}, },
expectedError: "NOVAMD_ADMIN_EMAIL and NOVAMD_ADMIN_PASSWORD must be set", expectedError: "LEMMA_ADMIN_EMAIL and LEMMA_ADMIN_PASSWORD must be set",
}, },
{ {
name: "missing admin password", name: "missing admin password",
setupEnv: func(t *testing.T) { setupEnv: func(t *testing.T) {
cleanup() cleanup()
setEnv(t, "NOVAMD_ADMIN_EMAIL", "admin@example.com") setEnv(t, "LEMMA_ADMIN_EMAIL", "admin@example.com")
setEnv(t, "NOVAMD_ENCRYPTION_KEY", "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=") setEnv(t, "LEMMA_ENCRYPTION_KEY", "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=")
}, },
expectedError: "NOVAMD_ADMIN_EMAIL and NOVAMD_ADMIN_PASSWORD must be set", expectedError: "LEMMA_ADMIN_EMAIL and LEMMA_ADMIN_PASSWORD must be set",
}, },
{ {
name: "missing encryption key", name: "missing encryption key",
setupEnv: func(t *testing.T) { setupEnv: func(t *testing.T) {
cleanup() cleanup()
setEnv(t, "NOVAMD_ADMIN_EMAIL", "admin@example.com") setEnv(t, "LEMMA_ADMIN_EMAIL", "admin@example.com")
setEnv(t, "NOVAMD_ADMIN_PASSWORD", "password123") setEnv(t, "LEMMA_ADMIN_PASSWORD", "password123")
}, },
expectedError: "invalid NOVAMD_ENCRYPTION_KEY: encryption key is required", expectedError: "invalid LEMMA_ENCRYPTION_KEY: encryption key is required",
}, },
{ {
name: "invalid encryption key", name: "invalid encryption key",
setupEnv: func(t *testing.T) { setupEnv: func(t *testing.T) {
cleanup() cleanup()
setEnv(t, "NOVAMD_ADMIN_EMAIL", "admin@example.com") setEnv(t, "LEMMA_ADMIN_EMAIL", "admin@example.com")
setEnv(t, "NOVAMD_ADMIN_PASSWORD", "password123") setEnv(t, "LEMMA_ADMIN_PASSWORD", "password123")
setEnv(t, "NOVAMD_ENCRYPTION_KEY", "invalid-key") setEnv(t, "LEMMA_ENCRYPTION_KEY", "invalid-key")
}, },
expectedError: "invalid NOVAMD_ENCRYPTION_KEY: invalid base64 encoding: illegal base64 data at input byte 7", expectedError: "invalid LEMMA_ENCRYPTION_KEY: invalid base64 encoding: illegal base64 data at input byte 7",
}, },
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
tc.setupEnv(t) tc.setupEnv(t)
_, err := config.Load() _, err := app.LoadConfig()
if err == nil { if err == nil {
t.Error("expected error, got nil") t.Error("expected error, got nil")
return return

120
server/internal/app/init.go Normal file
View File

@@ -0,0 +1,120 @@
// Package app provides application-level functionality for initializing and running the server
package app
import (
"fmt"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"lemma/internal/auth"
"lemma/internal/db"
"lemma/internal/logging"
"lemma/internal/models"
"lemma/internal/secrets"
"lemma/internal/storage"
)
// initSecretsService initializes the secrets service
func initSecretsService(cfg *Config) (secrets.Service, error) {
logging.Debug("initializing secrets service")
secretsService, err := secrets.NewService(cfg.EncryptionKey)
if err != nil {
return nil, fmt.Errorf("failed to initialize secrets service: %w", err)
}
return secretsService, nil
}
// initDatabase initializes and migrates the database
func initDatabase(cfg *Config, secretsService secrets.Service) (db.Database, error) {
logging.Debug("initializing database", "path", cfg.DBPath)
database, err := db.Init(cfg.DBPath, secretsService)
if err != nil {
return nil, fmt.Errorf("failed to initialize database: %w", err)
}
if err := database.Migrate(); err != nil {
return nil, fmt.Errorf("failed to apply database migrations: %w", err)
}
return database, nil
}
// initAuth initializes JWT and session services
func initAuth(cfg *Config, database db.Database) (auth.JWTManager, auth.SessionManager, auth.CookieManager, error) {
logging.Debug("initializing authentication services")
accessTokeExpiry := 15 * time.Minute
refreshTokenExpiry := 7 * 24 * time.Hour
// Get or generate JWT signing key
signingKey := cfg.JWTSigningKey
if signingKey == "" {
logging.Debug("no JWT signing key provided, generating new key")
var err error
signingKey, err = database.EnsureJWTSecret()
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to ensure JWT secret: %w", err)
}
}
jwtManager, err := auth.NewJWTService(auth.JWTConfig{
SigningKey: signingKey,
AccessTokenExpiry: accessTokeExpiry,
RefreshTokenExpiry: refreshTokenExpiry,
})
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to initialize JWT service: %w", err)
}
sessionManager := auth.NewSessionService(database, jwtManager)
cookieService := auth.NewCookieService(cfg.IsDevelopment, cfg.Domain)
return jwtManager, sessionManager, cookieService, nil
}
// setupAdminUser creates the admin user if it doesn't exist
func setupAdminUser(database db.Database, storageManager storage.Manager, cfg *Config) error {
// Check if admin user exists
adminUser, err := database.GetUserByEmail(cfg.AdminEmail)
if err != nil && !strings.Contains(err.Error(), "user not found") {
return fmt.Errorf("failed to check for existing admin user: %w", err)
}
if adminUser != nil {
logging.Debug("admin user already exists", "userId", adminUser.ID)
return nil
}
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash admin password: %w", err)
}
// Create admin user
adminUser = &models.User{
Email: cfg.AdminEmail,
DisplayName: "Admin",
PasswordHash: string(hashedPassword),
Role: models.RoleAdmin,
}
createdUser, err := database.CreateUser(adminUser)
if err != nil {
return fmt.Errorf("failed to create admin user: %w", err)
}
err = storageManager.InitializeUserWorkspace(createdUser.ID, createdUser.LastWorkspaceID)
if err != nil {
return fmt.Errorf("failed to initialize admin workspace: %w", err)
}
logging.Info("admin user setup completed",
"userId", createdUser.ID,
"workspaceId", createdUser.LastWorkspaceID)
return nil
}

View File

@@ -0,0 +1,59 @@
package app
import (
"lemma/internal/auth"
"lemma/internal/db"
"lemma/internal/logging"
"lemma/internal/storage"
)
// Options holds all dependencies and configuration for the server
type Options struct {
Config *Config
Database db.Database
Storage storage.Manager
JWTManager auth.JWTManager
SessionManager auth.SessionManager
CookieService auth.CookieManager
}
// DefaultOptions creates server options with default configuration
func DefaultOptions(cfg *Config) (*Options, error) {
// Initialize secrets service
secretsService, err := initSecretsService(cfg)
if err != nil {
return nil, err
}
// Initialize database
database, err := initDatabase(cfg, secretsService)
if err != nil {
return nil, err
}
// Initialize storage
storageManager := storage.NewService(cfg.WorkDir)
// Initialize logger
logging.Setup(cfg.LogLevel)
// Initialize auth services
jwtManager, sessionService, cookieService, err := initAuth(cfg, database)
if err != nil {
return nil, err
}
// Setup admin user
if err := setupAdminUser(database, storageManager, cfg); err != nil {
return nil, err
}
return &Options{
Config: cfg,
Database: database,
Storage: storageManager,
JWTManager: jwtManager,
SessionManager: sessionService,
CookieService: cookieService,
}, nil
}

View File

@@ -0,0 +1,155 @@
package app
import (
"lemma/internal/auth"
"lemma/internal/context"
"lemma/internal/handlers"
"lemma/internal/logging"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"github.com/unrolled/secure"
httpSwagger "github.com/swaggo/http-swagger"
_ "lemma/docs" // Swagger docs
)
// setupRouter creates and configures the chi router with middleware and routes
func setupRouter(o Options) *chi.Mux {
logging.Debug("setting up router")
r := chi.NewRouter()
// Basic middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Timeout(30 * time.Second))
// Security headers
r.Use(secure.New(secure.Options{
SSLRedirect: false,
SSLProxyHeaders: map[string]string{"X-Forwarded-Proto": "https"},
IsDevelopment: o.Config.IsDevelopment,
}).Handler)
// CORS if origins are configured
if len(o.Config.CORSOrigins) > 0 {
r.Use(cors.Handler(cors.Options{
AllowedOrigins: o.Config.CORSOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"X-CSRF-Token"},
AllowCredentials: true,
MaxAge: 300,
}))
}
// Initialize auth middleware and handler
authMiddleware := auth.NewMiddleware(o.JWTManager, o.SessionManager, o.CookieService)
handler := &handlers.Handler{
DB: o.Database,
Storage: o.Storage,
}
if o.Config.IsDevelopment {
r.Get("/swagger/*", httpSwagger.Handler(
httpSwagger.URL("/swagger/doc.json"), // The URL pointing to API definition
))
}
// API routes
r.Route("/api/v1", func(r chi.Router) {
// Rate limiting for API routes
if o.Config.RateLimitRequests > 0 {
r.Use(httprate.LimitByIP(
o.Config.RateLimitRequests,
o.Config.RateLimitWindow,
))
}
// Public routes (no authentication required)
r.Group(func(r chi.Router) {
r.Post("/auth/login", handler.Login(o.SessionManager, o.CookieService))
r.Post("/auth/refresh", handler.RefreshToken(o.SessionManager, o.CookieService))
})
// Protected routes (authentication required)
r.Group(func(r chi.Router) {
r.Use(authMiddleware.Authenticate)
r.Use(context.WithUserContextMiddleware)
// Auth routes
r.Post("/auth/logout", handler.Logout(o.SessionManager, o.CookieService))
r.Get("/auth/me", handler.GetCurrentUser())
// User profile routes
r.Put("/profile", handler.UpdateProfile())
r.Delete("/profile", handler.DeleteAccount())
// Admin-only routes
r.Route("/admin", func(r chi.Router) {
r.Use(authMiddleware.RequireRole("admin"))
// User management
r.Route("/users", func(r chi.Router) {
r.Get("/", handler.AdminListUsers())
r.Post("/", handler.AdminCreateUser())
r.Get("/{userId}", handler.AdminGetUser())
r.Put("/{userId}", handler.AdminUpdateUser())
r.Delete("/{userId}", handler.AdminDeleteUser())
})
// Workspace management
r.Route("/workspaces", func(r chi.Router) {
r.Get("/", handler.AdminListWorkspaces())
})
// System stats
r.Get("/stats", handler.AdminGetSystemStats())
})
// Workspace routes
r.Route("/workspaces", func(r chi.Router) {
r.Get("/", handler.ListWorkspaces())
r.Post("/", handler.CreateWorkspace())
r.Get("/last", handler.GetLastWorkspaceName())
r.Put("/last", handler.UpdateLastWorkspaceName())
// Single workspace routes
r.Route("/{workspaceName}", func(r chi.Router) {
r.Use(context.WithWorkspaceContextMiddleware(o.Database))
r.Use(authMiddleware.RequireWorkspaceAccess)
r.Get("/", handler.GetWorkspace())
r.Put("/", handler.UpdateWorkspace())
r.Delete("/", handler.DeleteWorkspace())
// File routes
r.Route("/files", func(r chi.Router) {
r.Get("/", handler.ListFiles())
r.Get("/last", handler.GetLastOpenedFile())
r.Put("/last", handler.UpdateLastOpenedFile())
r.Get("/lookup", handler.LookupFileByName())
r.Post("/*", handler.SaveFile())
r.Get("/*", handler.GetFileContent())
r.Delete("/*", handler.DeleteFile())
})
// Git routes
r.Route("/git", func(r chi.Router) {
r.Post("/commit", handler.StageCommitAndPush())
r.Post("/pull", handler.PullChanges())
})
})
})
})
})
// Handle all other routes with static file server
r.Get("/*", handlers.NewStaticHandler(o.Config.StaticPath).ServeHTTP)
return r
}

View File

@@ -0,0 +1,41 @@
package app
import (
"lemma/internal/logging"
"net/http"
"github.com/go-chi/chi/v5"
)
// Server represents the HTTP server and its dependencies
type Server struct {
router *chi.Mux
options *Options
}
// NewServer creates a new server instance with the given options
func NewServer(options *Options) *Server {
return &Server{
router: setupRouter(*options),
options: options,
}
}
// Start configures and starts the HTTP server
func (s *Server) Start() error {
// Start server
addr := ":" + s.options.Config.Port
logging.Info("starting server", "address", addr)
return http.ListenAndServe(addr, s.router)
}
// Close handles graceful shutdown of server dependencies
func (s *Server) Close() error {
logging.Info("shutting down server")
return s.options.Database.Close()
}
// Router returns the chi router for testing
func (s *Server) Router() chi.Router {
return s.router
}

View File

@@ -0,0 +1,137 @@
// Package auth provides JWT token generation and validation
package auth
import (
"lemma/internal/logging"
"net/http"
)
var logger logging.Logger
func getAuthLogger() logging.Logger {
if logger == nil {
logger = logging.WithGroup("auth")
}
return logger
}
func getCookieLogger() logging.Logger {
return getAuthLogger().WithGroup("cookie")
}
// CookieManager interface defines methods for generating cookies
type CookieManager interface {
GenerateAccessTokenCookie(token string) *http.Cookie
GenerateRefreshTokenCookie(token string) *http.Cookie
GenerateCSRFCookie(token string) *http.Cookie
InvalidateCookie(cookieType string) *http.Cookie
}
// CookieService
type cookieManager struct {
Domain string
Secure bool
SameSite http.SameSite
}
// NewCookieService creates a new cookie service
func NewCookieService(isDevelopment bool, domain string) CookieManager {
log := getCookieLogger()
secure := !isDevelopment
var sameSite http.SameSite
if isDevelopment {
sameSite = http.SameSiteLaxMode
} else {
sameSite = http.SameSiteStrictMode
}
log.Debug("creating cookie service",
"secure", secure,
"sameSite", sameSite,
"domain", domain)
return &cookieManager{
Domain: domain,
Secure: secure,
SameSite: sameSite,
}
}
// GenerateAccessTokenCookie creates a new cookie for the access token
func (c *cookieManager) GenerateAccessTokenCookie(token string) *http.Cookie {
log := getCookieLogger()
log.Debug("generating access token cookie",
"secure", c.Secure,
"sameSite", c.SameSite,
"maxAge", 900)
return &http.Cookie{
Name: "access_token",
Value: token,
HttpOnly: true,
Secure: c.Secure,
SameSite: c.SameSite,
Path: "/",
MaxAge: 900, // 15 minutes
}
}
// GenerateRefreshTokenCookie creates a new cookie for the refresh token
func (c *cookieManager) GenerateRefreshTokenCookie(token string) *http.Cookie {
log := getCookieLogger()
log.Debug("generating refresh token cookie",
"secure", c.Secure,
"sameSite", c.SameSite,
"maxAge", 604800)
return &http.Cookie{
Name: "refresh_token",
Value: token,
HttpOnly: true,
Secure: c.Secure,
SameSite: c.SameSite,
Path: "/",
MaxAge: 604800, // 7 days
}
}
// GenerateCSRFCookie creates a new cookie for the CSRF token
func (c *cookieManager) GenerateCSRFCookie(token string) *http.Cookie {
log := getCookieLogger()
log.Debug("generating CSRF cookie",
"secure", c.Secure,
"sameSite", c.SameSite,
"maxAge", 900,
"httpOnly", false)
return &http.Cookie{
Name: "csrf_token",
Value: token,
HttpOnly: false, // Frontend needs to read this
Secure: c.Secure,
SameSite: c.SameSite,
Path: "/",
MaxAge: 900,
}
}
// InvalidateCookie creates a new cookie with a MaxAge of -1 to invalidate the cookie
func (c *cookieManager) InvalidateCookie(cookieType string) *http.Cookie {
log := getCookieLogger()
log.Debug("invalidating cookie",
"type", cookieType,
"secure", c.Secure,
"sameSite", c.SameSite)
return &http.Cookie{
Name: cookieType,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: c.Secure,
SameSite: c.SameSite,
}
}

View File

@@ -3,13 +3,17 @@ package auth
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex"
"fmt" "fmt"
"lemma/internal/logging"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
func getJWTLogger() logging.Logger {
return getAuthLogger().WithGroup("jwt")
}
// TokenType represents the type of JWT token (access or refresh) // TokenType represents the type of JWT token (access or refresh)
type TokenType string type TokenType string
@@ -35,10 +39,9 @@ type JWTConfig struct {
// JWTManager defines the interface for managing JWT tokens // JWTManager defines the interface for managing JWT tokens
type JWTManager interface { type JWTManager interface {
GenerateAccessToken(userID int, role string) (string, error) GenerateAccessToken(userID int, role string, sessionID string) (string, error)
GenerateRefreshToken(userID int, role string) (string, error) GenerateRefreshToken(userID int, role string, sessionID string) (string, error)
ValidateToken(tokenString string) (*Claims, error) ValidateToken(tokenString string) (*Claims, error)
RefreshAccessToken(refreshToken string) (string, error)
} }
// jwtService handles JWT token generation and validation // jwtService handles JWT token generation and validation
@@ -52,28 +55,30 @@ func NewJWTService(config JWTConfig) (JWTManager, error) {
if config.SigningKey == "" { if config.SigningKey == "" {
return nil, fmt.Errorf("signing key is required") return nil, fmt.Errorf("signing key is required")
} }
// Set default expiry times if not provided // Set default expiry times if not provided
if config.AccessTokenExpiry == 0 { if config.AccessTokenExpiry == 0 {
config.AccessTokenExpiry = 15 * time.Minute // Default to 15 minutes config.AccessTokenExpiry = 15 * time.Minute
} }
if config.RefreshTokenExpiry == 0 { if config.RefreshTokenExpiry == 0 {
config.RefreshTokenExpiry = 7 * 24 * time.Hour // Default to 7 days config.RefreshTokenExpiry = 7 * 24 * time.Hour
} }
return &jwtService{config: config}, nil return &jwtService{config: config}, nil
} }
// GenerateAccessToken creates a new access token for a user with the given userID and role // GenerateAccessToken creates a new access token for a user with the given userID and role
func (s *jwtService) GenerateAccessToken(userID int, role string) (string, error) { func (s *jwtService) GenerateAccessToken(userID int, role, sessionID string) (string, error) {
return s.generateToken(userID, role, AccessToken, s.config.AccessTokenExpiry) return s.generateToken(userID, role, sessionID, AccessToken, s.config.AccessTokenExpiry)
} }
// GenerateRefreshToken creates a new refresh token for a user with the given userID and role // GenerateRefreshToken creates a new refresh token for a user with the given userID and role
func (s *jwtService) GenerateRefreshToken(userID int, role string) (string, error) { func (s *jwtService) GenerateRefreshToken(userID int, role, sessionID string) (string, error) {
return s.generateToken(userID, role, RefreshToken, s.config.RefreshTokenExpiry) return s.generateToken(userID, role, sessionID, RefreshToken, s.config.RefreshTokenExpiry)
} }
// generateToken is an internal helper function that creates a new JWT token // generateToken is an internal helper function that creates a new JWT token
func (s *jwtService) generateToken(userID int, role string, tokenType TokenType, expiry time.Duration) (string, error) { func (s *jwtService) generateToken(userID int, role string, sessionID string, tokenType TokenType, expiry time.Duration) (string, error) {
now := time.Now() now := time.Now()
// Add a random nonce to ensure uniqueness // Add a random nonce to ensure uniqueness
@@ -87,7 +92,7 @@ func (s *jwtService) generateToken(userID int, role string, tokenType TokenType,
ExpiresAt: jwt.NewNumericDate(now.Add(expiry)), ExpiresAt: jwt.NewNumericDate(now.Add(expiry)),
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now),
ID: hex.EncodeToString(nonce), ID: sessionID,
}, },
UserID: userID, UserID: userID,
Role: role, Role: role,
@@ -95,11 +100,18 @@ func (s *jwtService) generateToken(userID int, role string, tokenType TokenType,
} }
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.config.SigningKey)) signedToken, err := token.SignedString([]byte(s.config.SigningKey))
if err != nil {
return "", err
}
return signedToken, nil
} }
// ValidateToken validates and parses a JWT token // ValidateToken validates and parses a JWT token
func (s *jwtService) ValidateToken(tokenString string) (*Claims, error) { func (s *jwtService) ValidateToken(tokenString string) (*Claims, error) {
log := getJWTLogger()
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
// Validate the signing method // Validate the signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
@@ -112,23 +124,16 @@ func (s *jwtService) ValidateToken(tokenString string) (*Claims, error) {
return nil, fmt.Errorf("invalid token: %w", err) return nil, fmt.Errorf("invalid token: %w", err)
} }
if claims, ok := token.Claims.(*Claims); ok && token.Valid { claims, ok := token.Claims.(*Claims)
return claims, nil if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token claims")
} }
return nil, fmt.Errorf("invalid token claims") log.Debug("token validated",
} "userId", claims.UserID,
"role", claims.Role,
// RefreshAccessToken creates a new access token using a refreshToken "tokenType", claims.Type,
func (s *jwtService) RefreshAccessToken(refreshToken string) (string, error) { "expiresAt", claims.ExpiresAt)
claims, err := s.ValidateToken(refreshToken)
if err != nil { return claims, nil
return "", fmt.Errorf("invalid refresh token: %w", err)
}
if claims.Type != RefreshToken {
return "", fmt.Errorf("invalid token type: expected refresh token")
}
return s.GenerateAccessToken(claims.UserID, claims.Role)
} }

View File

@@ -1,16 +1,14 @@
// Package auth_test provides tests for the auth package
package auth_test package auth_test
import ( import (
"testing" "testing"
"time" "time"
"novamd/internal/auth" "lemma/internal/auth"
_ "lemma/internal/testenv"
"github.com/golang-jwt/jwt/v5"
) )
// jwt_test.go tests
func TestNewJWTService(t *testing.T) { func TestNewJWTService(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
@@ -100,9 +98,9 @@ func TestGenerateAndValidateToken(t *testing.T) {
// Generate token based on type // Generate token based on type
if tc.tokenType == auth.AccessToken { if tc.tokenType == auth.AccessToken {
token, err = service.GenerateAccessToken(tc.userID, tc.role) token, err = service.GenerateAccessToken(tc.userID, tc.role, "")
} else { } else {
token, err = service.GenerateRefreshToken(tc.userID, tc.role) token, err = service.GenerateRefreshToken(tc.userID, tc.role, "")
} }
if err != nil { if err != nil {
@@ -136,86 +134,3 @@ func TestGenerateAndValidateToken(t *testing.T) {
}) })
} }
} }
func TestRefreshAccessToken(t *testing.T) {
config := auth.JWTConfig{
SigningKey: "test-key",
AccessTokenExpiry: 15 * time.Minute,
RefreshTokenExpiry: 24 * time.Hour,
}
service, _ := auth.NewJWTService(config)
testCases := []struct {
name string
userID int
role string
wantErr bool
setupFunc func() string // Added setup function to handle custom token creation
}{
{
name: "valid refresh token",
userID: 1,
role: "admin",
wantErr: false,
setupFunc: func() string {
token, _ := service.GenerateRefreshToken(1, "admin")
return token
},
},
{
name: "expired refresh token",
userID: 1,
role: "admin",
wantErr: true,
setupFunc: func() string {
// Create a token that's already expired
claims := &auth.Claims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), // Expired 1 hour ago
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
},
UserID: 1,
Role: "admin",
Type: auth.RefreshToken,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, _ := token.SignedString([]byte(config.SigningKey))
return tokenString
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
refreshToken := tc.setupFunc()
newAccessToken, err := service.RefreshAccessToken(refreshToken)
if tc.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
claims, err := service.ValidateToken(newAccessToken)
if err != nil {
t.Fatalf("failed to validate new access token: %v", err)
}
if claims.UserID != tc.userID {
t.Errorf("userID = %v, want %v", claims.UserID, tc.userID)
}
if claims.Role != tc.role {
t.Errorf("role = %v, want %v", claims.Role, tc.role)
}
if claims.Type != auth.AccessToken {
t.Errorf("token type = %v, want %v", claims.Type, auth.AccessToken)
}
})
}
}

View File

@@ -1,54 +1,97 @@
package auth package auth
import ( import (
"crypto/subtle"
"lemma/internal/context"
"lemma/internal/logging"
"net/http" "net/http"
"strings"
"novamd/internal/context"
) )
func getMiddlewareLogger() logging.Logger {
return getAuthLogger().WithGroup("middleware")
}
// Middleware handles JWT authentication for protected routes // Middleware handles JWT authentication for protected routes
type Middleware struct { type Middleware struct {
jwtManager JWTManager jwtManager JWTManager
sessionManager SessionManager
cookieManager CookieManager
} }
// NewMiddleware creates a new authentication middleware // NewMiddleware creates a new authentication middleware
func NewMiddleware(jwtManager JWTManager) *Middleware { func NewMiddleware(jwtManager JWTManager, sessionManager SessionManager, cookieManager CookieManager) *Middleware {
return &Middleware{ return &Middleware{
jwtManager: jwtManager, jwtManager: jwtManager,
sessionManager: sessionManager,
cookieManager: cookieManager,
} }
} }
// Authenticate middleware validates JWT tokens and sets user information in context // Authenticate middleware validates JWT tokens and sets user information in context
func (m *Middleware) Authenticate(next http.Handler) http.Handler { func (m *Middleware) Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract token from Authorization header log := getMiddlewareLogger().With(
authHeader := r.Header.Get("Authorization") "handler", "Authenticate",
if authHeader == "" { "clientIP", r.RemoteAddr,
http.Error(w, "Authorization header required", http.StatusUnauthorized) )
return
}
// Check Bearer token format // Extract token from cookie
parts := strings.Split(authHeader, " ") cookie, err := r.Cookie("access_token")
if len(parts) != 2 || parts[0] != "Bearer" { if err != nil {
http.Error(w, "Invalid authorization format", http.StatusUnauthorized) log.Warn("attempt to access protected route without token")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
// Validate token // Validate token
claims, err := m.jwtManager.ValidateToken(parts[1]) claims, err := m.jwtManager.ValidateToken(cookie.Value)
if err != nil { if err != nil {
log.Warn("attempt to access protected route with invalid token", "error", err.Error())
http.Error(w, "Invalid token", http.StatusUnauthorized) http.Error(w, "Invalid token", http.StatusUnauthorized)
return return
} }
// Check token type // Check token type
if claims.Type != AccessToken { if claims.Type != AccessToken {
log.Warn("attempt to access protected route with invalid token type", "type", claims.Type)
http.Error(w, "Invalid token type", http.StatusUnauthorized) http.Error(w, "Invalid token type", http.StatusUnauthorized)
return return
} }
// Check if session is still valid in database
session, err := m.sessionManager.ValidateSession(claims.ID)
if err != nil || session == nil {
log.Warn("attempt to access protected route with invalid session", "error", err.Error())
m.cookieManager.InvalidateCookie("access_token")
m.cookieManager.InvalidateCookie("refresh_token")
m.cookieManager.InvalidateCookie("csrf_token")
http.Error(w, "Session invalid or expired", http.StatusUnauthorized)
return
}
// Add CSRF check for non-GET requests
if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions {
csrfCookie, err := r.Cookie("csrf_token")
if err != nil {
log.Warn("attempt to access protected route without CSRF token", "error", err.Error())
http.Error(w, "CSRF cookie not found", http.StatusForbidden)
return
}
csrfHeader := r.Header.Get("X-CSRF-Token")
if csrfHeader == "" {
log.Warn("attempt to access protected route without CSRF header")
http.Error(w, "CSRF token header not found", http.StatusForbidden)
return
}
if subtle.ConstantTimeCompare([]byte(csrfCookie.Value), []byte(csrfHeader)) != 1 {
log.Warn("attempt to access protected route with invalid CSRF token")
http.Error(w, "CSRF token mismatch", http.StatusForbidden)
return
}
}
// Create handler context with user information // Create handler context with user information
hctx := &context.HandlerContext{ hctx := &context.HandlerContext{
UserID: claims.UserID, UserID: claims.UserID,
@@ -64,12 +107,19 @@ func (m *Middleware) Authenticate(next http.Handler) http.Handler {
func (m *Middleware) RequireRole(role string) func(http.Handler) http.Handler { func (m *Middleware) RequireRole(role string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log := getMiddlewareLogger().With(
"handler", "RequireRole",
"requiredRole", role,
"clientIP", r.RemoteAddr,
)
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
if ctx.UserRole != role && ctx.UserRole != "admin" { if ctx.UserRole != role && ctx.UserRole != "admin" {
log.Warn("attempt to access protected route without required role")
http.Error(w, "Insufficient permissions", http.StatusForbidden) http.Error(w, "Insufficient permissions", http.StatusForbidden)
return return
} }
@@ -87,7 +137,13 @@ func (m *Middleware) RequireWorkspaceAccess(next http.Handler) http.Handler {
return return
} }
// If no workspace in context, allow the request (might be a non-workspace endpoint) log := getMiddlewareLogger().With(
"handler", "RequireWorkspaceAccess",
"clientIP", r.RemoteAddr,
"userId", ctx.UserID,
)
// If no workspace in context, allow the request
if ctx.Workspace == nil { if ctx.Workspace == nil {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
@@ -95,6 +151,7 @@ func (m *Middleware) RequireWorkspaceAccess(next http.Handler) http.Handler {
// Check if user has access (either owner or admin) // Check if user has access (either owner or admin)
if ctx.Workspace.UserID != ctx.UserID && ctx.UserRole != "admin" { if ctx.Workspace.UserID != ctx.UserID && ctx.UserRole != "admin" {
log.Warn("attempt to access workspace without permission")
http.Error(w, "Not Found", http.StatusNotFound) http.Error(w, "Not Found", http.StatusNotFound)
return return
} }

View File

@@ -1,16 +1,55 @@
package auth_test package auth_test
import ( import (
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"time" "time"
"novamd/internal/auth" "lemma/internal/auth"
"novamd/internal/context" "lemma/internal/context"
"novamd/internal/models" "lemma/internal/models"
_ "lemma/internal/testenv"
) )
// Mock SessionManager
type mockSessionManager struct {
sessions map[string]*models.Session
}
func newMockSessionManager() *mockSessionManager {
return &mockSessionManager{
sessions: make(map[string]*models.Session),
}
}
func (m *mockSessionManager) CreateSession(_ int, _ string) (*models.Session, string, error) {
return nil, "", nil // Not needed for these tests
}
func (m *mockSessionManager) RefreshSession(_ string) (string, error) {
return "", nil // Not needed for these tests
}
func (m *mockSessionManager) ValidateSession(sessionID string) (*models.Session, error) {
session, exists := m.sessions[sessionID]
if !exists {
return nil, fmt.Errorf("session not found")
}
return session, nil
}
func (m *mockSessionManager) InvalidateSession(token string) error {
delete(m.sessions, token)
return nil
}
func (m *mockSessionManager) CleanExpiredSessions() error {
return nil
}
// Complete mockResponseWriter implementation // Complete mockResponseWriter implementation
type mockResponseWriter struct { type mockResponseWriter struct {
headers http.Header headers http.Header
@@ -44,53 +83,108 @@ func TestAuthenticateMiddleware(t *testing.T) {
RefreshTokenExpiry: 24 * time.Hour, RefreshTokenExpiry: 24 * time.Hour,
} }
jwtService, _ := auth.NewJWTService(config) jwtService, _ := auth.NewJWTService(config)
middleware := auth.NewMiddleware(jwtService) sessionManager := newMockSessionManager()
cookieManager := auth.NewCookieService(true, "localhost")
middleware := auth.NewMiddleware(jwtService, sessionManager, cookieManager)
testCases := []struct { testCases := []struct {
name string name string
setupAuth func() string setupRequest func(sessionID string) *http.Request
setupSession func(sessionID string)
method string
wantStatusCode int wantStatusCode int
}{ }{
{ {
name: "valid token", name: "valid token with valid session",
setupAuth: func() string { setupRequest: func(sessionID string) *http.Request {
token, _ := jwtService.GenerateAccessToken(1, "admin") req := httptest.NewRequest("GET", "/test", nil)
return token token, _ := jwtService.GenerateAccessToken(1, "admin", sessionID)
cookie := cookieManager.GenerateAccessTokenCookie(token)
req.AddCookie(cookie)
return req
}, },
setupSession: func(sessionID string) {
sessionManager.sessions[sessionID] = &models.Session{
ID: sessionID,
UserID: 1,
ExpiresAt: time.Now().Add(15 * time.Minute),
}
},
method: "GET",
wantStatusCode: http.StatusOK, wantStatusCode: http.StatusOK,
}, },
{ {
name: "missing auth header", name: "valid token but invalid session",
setupAuth: func() string { setupRequest: func(sessionID string) *http.Request {
return "" req := httptest.NewRequest("GET", "/test", nil)
token, _ := jwtService.GenerateAccessToken(1, "admin", sessionID)
cookie := cookieManager.GenerateAccessTokenCookie(token)
req.AddCookie(cookie)
return req
}, },
setupSession: func(_ string) {}, // No session setup
method: "GET",
wantStatusCode: http.StatusUnauthorized, wantStatusCode: http.StatusUnauthorized,
}, },
{ {
name: "invalid auth format", name: "missing auth cookie",
setupAuth: func() string { setupRequest: func(_ string) *http.Request {
return "InvalidFormat token" return httptest.NewRequest("GET", "/test", nil)
}, },
setupSession: func(_ string) {},
method: "GET",
wantStatusCode: http.StatusUnauthorized, wantStatusCode: http.StatusUnauthorized,
}, },
{ {
name: "invalid token", name: "POST request without CSRF token",
setupAuth: func() string { setupRequest: func(sessionID string) *http.Request {
return "Bearer invalid.token.here" req := httptest.NewRequest("POST", "/test", nil)
token, _ := jwtService.GenerateAccessToken(1, "admin", sessionID)
cookie := cookieManager.GenerateAccessTokenCookie(token)
req.AddCookie(cookie)
return req
}, },
wantStatusCode: http.StatusUnauthorized, setupSession: func(sessionID string) {
sessionManager.sessions[sessionID] = &models.Session{
ID: sessionID,
UserID: 1,
ExpiresAt: time.Now().Add(15 * time.Minute),
}
},
method: "POST",
wantStatusCode: http.StatusForbidden,
},
{
name: "POST request with valid CSRF token",
setupRequest: func(sessionID string) *http.Request {
req := httptest.NewRequest("POST", "/test", nil)
token, _ := jwtService.GenerateAccessToken(1, "admin", sessionID)
cookie := cookieManager.GenerateAccessTokenCookie(token)
req.AddCookie(cookie)
csrfToken := "test-csrf-token"
csrfCookie := cookieManager.GenerateCSRFCookie(csrfToken)
req.AddCookie(csrfCookie)
req.Header.Set("X-CSRF-Token", csrfToken)
return req
},
setupSession: func(sessionID string) {
sessionManager.sessions[sessionID] = &models.Session{
ID: sessionID,
UserID: 1,
ExpiresAt: time.Now().Add(15 * time.Minute),
}
},
method: "POST",
wantStatusCode: http.StatusOK,
}, },
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Create test request sessionID := tc.name
req := httptest.NewRequest("GET", "/test", nil)
if token := tc.setupAuth(); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
// Create response recorder req := tc.setupRequest(sessionID)
w := newMockResponseWriter() w := newMockResponseWriter()
// Create test handler // Create test handler
@@ -100,6 +194,13 @@ func TestAuthenticateMiddleware(t *testing.T) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
// If we have a valid token, set up the session
if cookie, err := req.Cookie("access_token"); err == nil {
if claims, err := jwtService.ValidateToken(cookie.Value); err == nil {
tc.setupSession(claims.ID)
}
}
// Execute middleware // Execute middleware
middleware.Authenticate(next).ServeHTTP(w, req) middleware.Authenticate(next).ServeHTTP(w, req)
@@ -115,6 +216,15 @@ func TestAuthenticateMiddleware(t *testing.T) {
if tc.wantStatusCode != http.StatusOK && nextCalled { if tc.wantStatusCode != http.StatusOK && nextCalled {
t.Error("next handler was called when it shouldn't have been") t.Error("next handler was called when it shouldn't have been")
} }
// For unauthorized responses, check if cookies were invalidated
if w.statusCode == http.StatusUnauthorized {
for _, cookie := range w.Header()["Set-Cookie"] {
if strings.Contains(cookie, "Max-Age=0") {
t.Error("cookies were not properly invalidated")
}
}
}
}) })
} }
} }
@@ -126,7 +236,7 @@ func TestRequireRole(t *testing.T) {
RefreshTokenExpiry: 24 * time.Hour, RefreshTokenExpiry: 24 * time.Hour,
} }
jwtService, _ := auth.NewJWTService(config) jwtService, _ := auth.NewJWTService(config)
middleware := auth.NewMiddleware(jwtService) middleware := auth.NewMiddleware(jwtService, &mockSessionManager{}, auth.NewCookieService(true, "localhost"))
testCases := []struct { testCases := []struct {
name string name string
@@ -198,7 +308,7 @@ func TestRequireWorkspaceAccess(t *testing.T) {
SigningKey: "test-key", SigningKey: "test-key",
} }
jwtService, _ := auth.NewJWTService(config) jwtService, _ := auth.NewJWTService(config)
middleware := auth.NewMiddleware(jwtService) middleware := auth.NewMiddleware(jwtService, &mockSessionManager{}, auth.NewCookieService(true, "localhost"))
testCases := []struct { testCases := []struct {
name string name string

View File

@@ -2,36 +2,56 @@ package auth
import ( import (
"fmt" "fmt"
"novamd/internal/db" "lemma/internal/db"
"novamd/internal/models" "lemma/internal/logging"
"lemma/internal/models"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
) )
// SessionService manages user sessions in the database func getSessionLogger() logging.Logger {
type SessionService struct { return getAuthLogger().WithGroup("session")
}
// SessionManager is an interface for managing user sessions
type SessionManager interface {
CreateSession(userID int, role string) (*models.Session, string, error)
RefreshSession(refreshToken string) (string, error)
ValidateSession(sessionID string) (*models.Session, error)
InvalidateSession(token string) error
CleanExpiredSessions() error
}
// sessionManager manages user sessions in the database
type sessionManager struct {
db db.SessionStore // Database store for sessions db db.SessionStore // Database store for sessions
jwtManager JWTManager // JWT Manager for token operations jwtManager JWTManager // JWT Manager for token operations
} }
// NewSessionService creates a new session service with the given database and JWT manager // NewSessionService creates a new session service with the given database and JWT manager
func NewSessionService(db db.SessionStore, jwtManager JWTManager) *SessionService { // revive:disable:unexported-return
return &SessionService{ func NewSessionService(db db.SessionStore, jwtManager JWTManager) *sessionManager {
return &sessionManager{
db: db, db: db,
jwtManager: jwtManager, jwtManager: jwtManager,
} }
} }
// CreateSession creates a new user session for a user with the given userID and role // CreateSession creates a new user session for a user with the given userID and role
func (s *SessionService) CreateSession(userID int, role string) (*models.Session, string, error) { func (s *sessionManager) CreateSession(userID int, role string) (*models.Session, string, error) {
log := getSessionLogger()
// Generate a new session ID
sessionID := uuid.New().String()
// Generate both access and refresh tokens // Generate both access and refresh tokens
accessToken, err := s.jwtManager.GenerateAccessToken(userID, role) accessToken, err := s.jwtManager.GenerateAccessToken(userID, role, sessionID)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("failed to generate access token: %w", err) return nil, "", fmt.Errorf("failed to generate access token: %w", err)
} }
refreshToken, err := s.jwtManager.GenerateRefreshToken(userID, role) refreshToken, err := s.jwtManager.GenerateRefreshToken(userID, role, sessionID)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("failed to generate refresh token: %w", err) return nil, "", fmt.Errorf("failed to generate refresh token: %w", err)
} }
@@ -44,7 +64,7 @@ func (s *SessionService) CreateSession(userID int, role string) (*models.Session
// Create a new session record // Create a new session record
session := &models.Session{ session := &models.Session{
ID: uuid.New().String(), ID: sessionID,
UserID: userID, UserID: userID,
RefreshToken: refreshToken, RefreshToken: refreshToken,
ExpiresAt: claims.ExpiresAt.Time, ExpiresAt: claims.ExpiresAt.Time,
@@ -56,12 +76,18 @@ func (s *SessionService) CreateSession(userID int, role string) (*models.Session
return nil, "", err return nil, "", err
} }
log.Debug("created new session",
"userId", userID,
"role", role,
"sessionId", sessionID,
"expiresAt", claims.ExpiresAt.Time)
return session, accessToken, nil return session, accessToken, nil
} }
// RefreshSession creates a new access token using a refreshToken // RefreshSession creates a new access token using a refreshToken
func (s *SessionService) RefreshSession(refreshToken string) (string, error) { func (s *sessionManager) RefreshSession(refreshToken string) (string, error) {
// Get session from database first // Get session from database
session, err := s.db.GetSessionByRefreshToken(refreshToken) session, err := s.db.GetSessionByRefreshToken(refreshToken)
if err != nil { if err != nil {
return "", fmt.Errorf("invalid session: %w", err) return "", fmt.Errorf("invalid session: %w", err)
@@ -73,21 +99,66 @@ func (s *SessionService) RefreshSession(refreshToken string) (string, error) {
return "", fmt.Errorf("invalid refresh token: %w", err) return "", fmt.Errorf("invalid refresh token: %w", err)
} }
// Double check that the claims match the session
if claims.UserID != session.UserID { if claims.UserID != session.UserID {
return "", fmt.Errorf("token does not match session") return "", fmt.Errorf("token does not match session")
} }
// Generate a new access token // Generate a new access token
return s.jwtManager.GenerateAccessToken(claims.UserID, claims.Role) newToken, err := s.jwtManager.GenerateAccessToken(claims.UserID, claims.Role, session.ID)
if err != nil {
return "", err
}
return newToken, nil
}
// ValidateSession checks if a session with the given sessionID is valid
func (s *sessionManager) ValidateSession(sessionID string) (*models.Session, error) {
log := getSessionLogger()
// Get the session from the database
session, err := s.db.GetSessionByID(sessionID)
if err != nil {
return nil, fmt.Errorf("failed to get session: %w", err)
}
log.Debug("validated session",
"sessionId", sessionID,
"userId", session.UserID,
"expiresAt", session.ExpiresAt)
return session, nil
} }
// InvalidateSession removes a session with the given sessionID from the database // InvalidateSession removes a session with the given sessionID from the database
func (s *SessionService) InvalidateSession(sessionID string) error { func (s *sessionManager) InvalidateSession(token string) error {
return s.db.DeleteSession(sessionID) log := getSessionLogger()
// Parse the JWT to get the session info
claims, err := s.jwtManager.ValidateToken(token)
if err != nil {
return fmt.Errorf("invalid token: %w", err)
}
if err := s.db.DeleteSession(claims.ID); err != nil {
return err
}
log.Debug("invalidated session",
"sessionId", claims.ID,
"userId", claims.UserID)
return nil
} }
// CleanExpiredSessions removes all expired sessions from the database // CleanExpiredSessions removes all expired sessions from the database
func (s *SessionService) CleanExpiredSessions() error { func (s *sessionManager) CleanExpiredSessions() error {
return s.db.CleanExpiredSessions() log := getSessionLogger()
if err := s.db.CleanExpiredSessions(); err != nil {
return err
}
log.Info("cleaned expired sessions")
return nil
} }

View File

@@ -6,14 +6,15 @@ import (
"testing" "testing"
"time" "time"
"novamd/internal/auth" "lemma/internal/auth"
"novamd/internal/models" "lemma/internal/models"
_ "lemma/internal/testenv"
) )
// Mock SessionStore // Mock SessionStore
type mockSessionStore struct { type mockSessionStore struct {
sessions map[string]*models.Session sessions map[string]*models.Session
sessionsByToken map[string]*models.Session // Added index by refresh token sessionsByToken map[string]*models.Session
} }
func newMockSessionStore() *mockSessionStore { func newMockSessionStore() *mockSessionStore {
@@ -29,6 +30,17 @@ func (m *mockSessionStore) CreateSession(session *models.Session) error {
return nil return nil
} }
func (m *mockSessionStore) GetSessionByID(sessionID string) (*models.Session, error) {
session, exists := m.sessions[sessionID]
if !exists {
return nil, errors.New("session not found")
}
if session.ExpiresAt.Before(time.Now()) {
return nil, errors.New("session expired")
}
return session, nil
}
func (m *mockSessionStore) GetSessionByRefreshToken(refreshToken string) (*models.Session, error) { func (m *mockSessionStore) GetSessionByRefreshToken(refreshToken string) (*models.Session, error) {
session, exists := m.sessionsByToken[refreshToken] session, exists := m.sessionsByToken[refreshToken]
if !exists { if !exists {
@@ -111,9 +123,9 @@ func TestCreateSession(t *testing.T) {
} }
// Verify the session was stored // Verify the session was stored
storedSession, exists := mockDB.sessions[session.ID] storedSession, err := mockDB.GetSessionByID(session.ID)
if !exists { if err != nil {
t.Error("session was not stored in database") t.Errorf("failed to get stored session: %v", err)
} }
if storedSession.RefreshToken != session.RefreshToken { if storedSession.RefreshToken != session.RefreshToken {
t.Error("stored refresh token doesn't match") t.Error("stored refresh token doesn't match")
@@ -138,6 +150,97 @@ func TestCreateSession(t *testing.T) {
} }
} }
func TestValidateSession(t *testing.T) {
config := auth.JWTConfig{
SigningKey: "test-key",
AccessTokenExpiry: 15 * time.Minute,
RefreshTokenExpiry: 24 * time.Hour,
}
jwtService, _ := auth.NewJWTService(config)
mockDB := newMockSessionStore()
sessionService := auth.NewSessionService(mockDB, jwtService)
testCases := []struct {
name string
setupSession func() string
wantErr bool
errorContains string
}{
{
name: "valid session",
setupSession: func() string {
session := &models.Session{
ID: "test-session-1",
UserID: 1,
ExpiresAt: time.Now().Add(24 * time.Hour),
CreatedAt: time.Now(),
}
if err := mockDB.CreateSession(session); err != nil {
t.Fatalf("failed to create session: %v", err)
}
return session.ID
},
wantErr: false,
},
{
name: "expired session",
setupSession: func() string {
session := &models.Session{
ID: "test-session-2",
UserID: 1,
ExpiresAt: time.Now().Add(-1 * time.Hour),
CreatedAt: time.Now().Add(-2 * time.Hour),
}
if err := mockDB.CreateSession(session); err != nil {
t.Fatalf("failed to create session: %v", err)
}
return session.ID
},
wantErr: true,
errorContains: "session expired",
},
{
name: "non-existent session",
setupSession: func() string {
return "non-existent-session-id"
},
wantErr: true,
errorContains: "session not found",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
sessionID := tc.setupSession()
session, err := sessionService.ValidateSession(sessionID)
if tc.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if tc.errorContains != "" && !strings.Contains(err.Error(), tc.errorContains) {
t.Errorf("error = %v, want error containing %v", err, tc.errorContains)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if session == nil {
t.Error("expected session, got nil")
return
}
if session.ID != sessionID {
t.Errorf("session ID = %v, want %v", session.ID, sessionID)
}
})
}
}
func TestRefreshSession(t *testing.T) { func TestRefreshSession(t *testing.T) {
config := auth.JWTConfig{ config := auth.JWTConfig{
SigningKey: "test-key", SigningKey: "test-key",
@@ -157,7 +260,7 @@ func TestRefreshSession(t *testing.T) {
{ {
name: "valid refresh token", name: "valid refresh token",
setupSession: func() string { setupSession: func() string {
token, _ := jwtService.GenerateRefreshToken(1, "admin") token, _ := jwtService.GenerateRefreshToken(1, "admin", "test-session-1")
session := &models.Session{ session := &models.Session{
ID: "test-session-1", ID: "test-session-1",
UserID: 1, UserID: 1,
@@ -175,12 +278,12 @@ func TestRefreshSession(t *testing.T) {
{ {
name: "expired refresh token", name: "expired refresh token",
setupSession: func() string { setupSession: func() string {
token, _ := jwtService.GenerateRefreshToken(1, "admin") token, _ := jwtService.GenerateRefreshToken(1, "admin", "test-session-2")
session := &models.Session{ session := &models.Session{
ID: "test-session-2", ID: "test-session-2",
UserID: 1, UserID: 1,
RefreshToken: token, RefreshToken: token,
ExpiresAt: time.Now().Add(-1 * time.Hour), // Expired ExpiresAt: time.Now().Add(-1 * time.Hour),
CreatedAt: time.Now().Add(-2 * time.Hour), CreatedAt: time.Now().Add(-2 * time.Hour),
} }
if err := mockDB.CreateSession(session); err != nil { if err := mockDB.CreateSession(session); err != nil {
@@ -233,7 +336,7 @@ func TestRefreshSession(t *testing.T) {
} }
} }
func TestInvalidateSession(t *testing.T) { func TestCleanExpiredSessions(t *testing.T) {
config := auth.JWTConfig{ config := auth.JWTConfig{
SigningKey: "test-key", SigningKey: "test-key",
AccessTokenExpiry: 15 * time.Minute, AccessTokenExpiry: 15 * time.Minute,
@@ -243,62 +346,40 @@ func TestInvalidateSession(t *testing.T) {
mockDB := newMockSessionStore() mockDB := newMockSessionStore()
sessionService := auth.NewSessionService(mockDB, jwtService) sessionService := auth.NewSessionService(mockDB, jwtService)
testCases := []struct { // Create test sessions
name string validSession := &models.Session{
setupSession func() string ID: "valid-session",
wantErr bool UserID: 1,
errorContains string ExpiresAt: time.Now().Add(24 * time.Hour),
}{ CreatedAt: time.Now(),
{ }
name: "valid session invalidation", if err := mockDB.CreateSession(validSession); err != nil {
setupSession: func() string { t.Fatalf("failed to create valid session: %v", err)
session := &models.Session{
ID: "test-session-1",
UserID: 1,
RefreshToken: "valid-token",
ExpiresAt: time.Now().Add(24 * time.Hour),
CreatedAt: time.Now(),
}
if err := mockDB.CreateSession(session); err != nil {
t.Fatalf("failed to create session: %v", err)
}
return session.ID
},
wantErr: false,
},
{
name: "non-existent session",
setupSession: func() string {
return "non-existent-session-id"
},
wantErr: true,
errorContains: "session not found",
},
} }
for _, tc := range testCases { expiredSession := &models.Session{
t.Run(tc.name, func(t *testing.T) { ID: "expired-session",
sessionID := tc.setupSession() UserID: 2,
err := sessionService.InvalidateSession(sessionID) ExpiresAt: time.Now().Add(-1 * time.Hour),
CreatedAt: time.Now().Add(-2 * time.Hour),
}
if err := mockDB.CreateSession(expiredSession); err != nil {
t.Fatalf("failed to create expired session: %v", err)
}
if tc.wantErr { // Clean expired sessions
if err == nil { err := sessionService.CleanExpiredSessions()
t.Error("expected error, got nil") if err != nil {
} else if !strings.Contains(err.Error(), tc.errorContains) { t.Errorf("unexpected error cleaning sessions: %v", err)
t.Errorf("error = %v, want error containing %v", err, tc.errorContains) }
}
return
}
if err != nil { // Verify valid session still exists
t.Errorf("unexpected error: %v", err) if _, err := mockDB.GetSessionByID(validSession.ID); err != nil {
return t.Error("valid session was incorrectly removed")
} }
// Verify session was removed // Verify expired session was removed
if _, exists := mockDB.sessions[sessionID]; exists { if _, err := mockDB.GetSessionByID(expiredSession.ID); err == nil {
t.Error("session still exists after invalidation") t.Error("expired session was not removed")
}
})
} }
} }

View File

@@ -1,113 +0,0 @@
// Package config provides the configuration for the application
package config
import (
"fmt"
"novamd/internal/secrets"
"os"
"strconv"
"strings"
"time"
)
// Config holds the configuration for the application
type Config struct {
DBPath string
WorkDir string
StaticPath string
Port string
AppURL string
CORSOrigins []string
AdminEmail string
AdminPassword string
EncryptionKey string
JWTSigningKey string
RateLimitRequests int
RateLimitWindow time.Duration
IsDevelopment bool
}
// DefaultConfig returns a new Config instance with default values
func DefaultConfig() *Config {
return &Config{
DBPath: "./novamd.db",
WorkDir: "./data",
StaticPath: "../app/dist",
Port: "8080",
RateLimitRequests: 100,
RateLimitWindow: time.Minute * 15,
IsDevelopment: false,
}
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
if c.AdminEmail == "" || c.AdminPassword == "" {
return fmt.Errorf("NOVAMD_ADMIN_EMAIL and NOVAMD_ADMIN_PASSWORD must be set")
}
// Validate encryption key
if err := secrets.ValidateKey(c.EncryptionKey); err != nil {
return fmt.Errorf("invalid NOVAMD_ENCRYPTION_KEY: %w", err)
}
return nil
}
// Load creates a new Config instance with values from environment variables
func Load() (*Config, error) {
config := DefaultConfig()
if env := os.Getenv("NOVAMD_ENV"); env != "" {
config.IsDevelopment = env == "development"
}
if dbPath := os.Getenv("NOVAMD_DB_PATH"); dbPath != "" {
config.DBPath = dbPath
}
if workDir := os.Getenv("NOVAMD_WORKDIR"); workDir != "" {
config.WorkDir = workDir
}
if staticPath := os.Getenv("NOVAMD_STATIC_PATH"); staticPath != "" {
config.StaticPath = staticPath
}
if port := os.Getenv("NOVAMD_PORT"); port != "" {
config.Port = port
}
if appURL := os.Getenv("NOVAMD_APP_URL"); appURL != "" {
config.AppURL = appURL
}
if corsOrigins := os.Getenv("NOVAMD_CORS_ORIGINS"); corsOrigins != "" {
config.CORSOrigins = strings.Split(corsOrigins, ",")
}
config.AdminEmail = os.Getenv("NOVAMD_ADMIN_EMAIL")
config.AdminPassword = os.Getenv("NOVAMD_ADMIN_PASSWORD")
config.EncryptionKey = os.Getenv("NOVAMD_ENCRYPTION_KEY")
config.JWTSigningKey = os.Getenv("NOVAMD_JWT_SIGNING_KEY")
// Configure rate limiting
if reqStr := os.Getenv("NOVAMD_RATE_LIMIT_REQUESTS"); reqStr != "" {
if parsed, err := strconv.Atoi(reqStr); err == nil {
config.RateLimitRequests = parsed
}
}
if windowStr := os.Getenv("NOVAMD_RATE_LIMIT_WINDOW"); windowStr != "" {
if parsed, err := time.ParseDuration(windowStr); err == nil {
config.RateLimitWindow = parsed
}
}
// Validate all settings
if err := config.Validate(); err != nil {
return nil, err
}
return config, nil
}

View File

@@ -4,8 +4,9 @@ package context
import ( import (
"context" "context"
"fmt" "fmt"
"lemma/internal/logging"
"lemma/internal/models"
"net/http" "net/http"
"novamd/internal/models"
) )
type contextKey string type contextKey string
@@ -28,10 +29,22 @@ type HandlerContext struct {
Workspace *models.Workspace // Optional, only set for workspace routes Workspace *models.Workspace // Optional, only set for workspace routes
} }
var logger logging.Logger
func getLogger() logging.Logger {
if logger == nil {
logger = logging.WithGroup("context")
}
return logger
}
// GetRequestContext retrieves the handler context from the request // GetRequestContext retrieves the handler context from the request
func GetRequestContext(w http.ResponseWriter, r *http.Request) (*HandlerContext, bool) { func GetRequestContext(w http.ResponseWriter, r *http.Request) (*HandlerContext, bool) {
ctx := r.Context().Value(HandlerContextKey) ctx := r.Context().Value(HandlerContextKey)
if ctx == nil { if ctx == nil {
getLogger().Error("missing handler context in request",
"path", r.URL.Path,
"method", r.Method)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return nil, false return nil, false
} }

View File

@@ -6,7 +6,8 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"novamd/internal/context" "lemma/internal/context"
_ "lemma/internal/testenv"
) )
func TestGetRequestContext(t *testing.T) { func TestGetRequestContext(t *testing.T) {

View File

@@ -1,8 +1,8 @@
package context package context
import ( import (
"lemma/internal/db"
"net/http" "net/http"
"novamd/internal/db"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -10,9 +10,13 @@ import (
// WithUserContextMiddleware extracts user information from JWT claims // WithUserContextMiddleware extracts user information from JWT claims
// and adds it to the request context // and adds it to the request context
func WithUserContextMiddleware(next http.Handler) http.Handler { func WithUserContextMiddleware(next http.Handler) http.Handler {
log := getLogger()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, err := GetUserFromContext(r.Context()) claims, err := GetUserFromContext(r.Context())
if err != nil { if err != nil {
log.Error("failed to get user from context",
"error", err,
"path", r.URL.Path)
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
@@ -30,6 +34,7 @@ func WithUserContextMiddleware(next http.Handler) http.Handler {
// WithWorkspaceContextMiddleware adds workspace information to the request context // WithWorkspaceContextMiddleware adds workspace information to the request context
func WithWorkspaceContextMiddleware(db db.WorkspaceReader) func(http.Handler) http.Handler { func WithWorkspaceContextMiddleware(db db.WorkspaceReader) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
log := getLogger()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, ok := GetRequestContext(w, r) ctx, ok := GetRequestContext(w, r)
if !ok { if !ok {
@@ -39,11 +44,15 @@ func WithWorkspaceContextMiddleware(db db.WorkspaceReader) func(http.Handler) ht
workspaceName := chi.URLParam(r, "workspaceName") workspaceName := chi.URLParam(r, "workspaceName")
workspace, err := db.GetWorkspaceByName(ctx.UserID, workspaceName) workspace, err := db.GetWorkspaceByName(ctx.UserID, workspaceName)
if err != nil { if err != nil {
http.Error(w, "Workspace not found", http.StatusNotFound) log.Error("failed to get workspace",
"error", err,
"userID", ctx.UserID,
"workspace", workspaceName,
"path", r.URL.Path)
http.Error(w, "Failed to get workspace", http.StatusNotFound)
return return
} }
// Update existing context with workspace
ctx.Workspace = workspace ctx.Workspace = workspace
r = WithHandlerContext(r, ctx) r = WithHandlerContext(r, ctx)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)

View File

@@ -7,8 +7,9 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"novamd/internal/context" "lemma/internal/context"
"novamd/internal/models" "lemma/internal/models"
_ "lemma/internal/testenv"
) )
// MockDB implements the minimal database interface needed for testing // MockDB implements the minimal database interface needed for testing
@@ -89,6 +90,10 @@ func TestWithUserContextMiddleware(t *testing.T) {
} }
} }
type contextKey string
const workspaceNameKey contextKey = "workspaceName"
func TestWithWorkspaceContextMiddleware(t *testing.T) { func TestWithWorkspaceContextMiddleware(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -158,7 +163,7 @@ func TestWithWorkspaceContextMiddleware(t *testing.T) {
} }
// Add workspace name to request context via chi URL params // Add workspace name to request context via chi URL params
req = req.WithContext(stdctx.WithValue(req.Context(), "workspaceName", tt.workspaceName)) req = req.WithContext(stdctx.WithValue(req.Context(), workspaceNameKey, tt.workspaceName))
nextCalled := false nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -3,9 +3,11 @@ package db
import ( import (
"database/sql" "database/sql"
"fmt"
"novamd/internal/models" "lemma/internal/logging"
"novamd/internal/secrets" "lemma/internal/models"
"lemma/internal/secrets"
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
) )
@@ -53,6 +55,7 @@ type WorkspaceStore interface {
type SessionStore interface { type SessionStore interface {
CreateSession(session *models.Session) error CreateSession(session *models.Session) error
GetSessionByRefreshToken(refreshToken string) (*models.Session, error) GetSessionByRefreshToken(refreshToken string) (*models.Session, error)
GetSessionByID(sessionID string) (*models.Session, error)
DeleteSession(sessionID string) error DeleteSession(sessionID string) error
CleanExpiredSessions() error CleanExpiredSessions() error
} }
@@ -76,6 +79,7 @@ type Database interface {
Migrate() error Migrate() error
} }
// Verify that the database implements the required interfaces
var ( var (
// Main Database interface // Main Database interface
_ Database = (*database)(nil) _ Database = (*database)(nil)
@@ -91,6 +95,15 @@ var (
_ WorkspaceWriter = (*database)(nil) _ WorkspaceWriter = (*database)(nil)
) )
var logger logging.Logger
func getLogger() logging.Logger {
if logger == nil {
logger = logging.WithGroup("db")
}
return logger
}
// database represents the database connection // database represents the database connection
type database struct { type database struct {
*sql.DB *sql.DB
@@ -99,19 +112,22 @@ type database struct {
// Init initializes the database connection // Init initializes the database connection
func Init(dbPath string, secretsService secrets.Service) (Database, error) { func Init(dbPath string, secretsService secrets.Service) (Database, error) {
log := getLogger()
db, err := sql.Open("sqlite3", dbPath) db, err := sql.Open("sqlite3", dbPath)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to open database: %w", err)
} }
if err := db.Ping(); err != nil { if err := db.Ping(); err != nil {
return nil, err return nil, fmt.Errorf("failed to ping database: %w", err)
} }
// Enable foreign keys for this connection // Enable foreign keys for this connection
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
return nil, err return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
} }
log.Debug("foreign keys enabled")
database := &database{ database := &database{
DB: db, DB: db,
@@ -123,7 +139,13 @@ func Init(dbPath string, secretsService secrets.Service) (Database, error) {
// Close closes the database connection // Close closes the database connection
func (db *database) Close() error { func (db *database) Close() error {
return db.DB.Close() log := getLogger()
log.Info("closing database connection")
if err := db.DB.Close(); err != nil {
return fmt.Errorf("failed to close database: %w", err)
}
return nil
} }
// Helper methods for token encryption/decryption // Helper methods for token encryption/decryption
@@ -131,12 +153,24 @@ func (db *database) encryptToken(token string) (string, error) {
if token == "" { if token == "" {
return "", nil return "", nil
} }
return db.secretsService.Encrypt(token)
encrypted, err := db.secretsService.Encrypt(token)
if err != nil {
return "", fmt.Errorf("failed to encrypt token: %w", err)
}
return encrypted, nil
} }
func (db *database) decryptToken(token string) (string, error) { func (db *database) decryptToken(token string) (string, error) {
if token == "" { if token == "" {
return "", nil return "", nil
} }
return db.secretsService.Decrypt(token)
decrypted, err := db.secretsService.Decrypt(token)
if err != nil {
return "", fmt.Errorf("failed to decrypt token: %w", err)
}
return decrypted, nil
} }

View File

@@ -2,7 +2,6 @@ package db
import ( import (
"fmt" "fmt"
"log"
) )
// Migration represents a database migration // Migration represents a database migration
@@ -79,56 +78,64 @@ var migrations = []Migration{
// Migrate applies all database migrations // Migrate applies all database migrations
func (db *database) Migrate() error { func (db *database) Migrate() error {
log := getLogger().WithGroup("migrations")
log.Info("starting database migration")
// Create migrations table if it doesn't exist // Create migrations table if it doesn't exist
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS migrations ( _, err := db.Exec(`CREATE TABLE IF NOT EXISTS migrations (
version INTEGER PRIMARY KEY version INTEGER PRIMARY KEY
)`) )`)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to create migrations table: %w", err)
} }
// Get current version // Get current version
var currentVersion int var currentVersion int
err = db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM migrations").Scan(&currentVersion) err = db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM migrations").Scan(&currentVersion)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to get current migration version: %w", err)
} }
// Apply new migrations // Apply new migrations
for _, migration := range migrations { for _, migration := range migrations {
if migration.Version > currentVersion { if migration.Version > currentVersion {
log.Printf("Applying migration %d", migration.Version) log := log.With("migration_version", migration.Version)
tx, err := db.Begin() tx, err := db.Begin()
if err != nil { if err != nil {
return err return fmt.Errorf("failed to begin transaction for migration %d: %w", migration.Version, err)
} }
// Execute migration SQL
_, err = tx.Exec(migration.SQL) _, err = tx.Exec(migration.SQL)
if err != nil { if err != nil {
if rbErr := tx.Rollback(); rbErr != nil { if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("migration %d failed: %v, rollback failed: %v", migration.Version, err, rbErr) return fmt.Errorf("migration %d failed: %v, rollback failed: %v",
migration.Version, err, rbErr)
} }
return fmt.Errorf("migration %d failed: %v", migration.Version, err) return fmt.Errorf("migration %d failed: %w", migration.Version, err)
} }
// Update migrations table
_, err = tx.Exec("INSERT INTO migrations (version) VALUES (?)", migration.Version) _, err = tx.Exec("INSERT INTO migrations (version) VALUES (?)", migration.Version)
if err != nil { if err != nil {
if rbErr := tx.Rollback(); rbErr != nil { if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("failed to update migration version: %v, rollback failed: %v", err, rbErr) return fmt.Errorf("failed to update migration version: %v, rollback failed: %v",
err, rbErr)
} }
return fmt.Errorf("failed to update migration version: %v", err) return fmt.Errorf("failed to update migration version: %w", err)
} }
// Commit transaction
err = tx.Commit() err = tx.Commit()
if err != nil { if err != nil {
return fmt.Errorf("failed to commit migration %d: %v", migration.Version, err) return fmt.Errorf("failed to commit migration %d: %w", migration.Version, err)
} }
currentVersion = migration.Version currentVersion = migration.Version
log.Debug("migration applied", "new_version", currentVersion)
} }
} }
log.Printf("Database is at version %d", currentVersion) log.Info("database migration completed", "final_version", currentVersion)
return nil return nil
} }

View File

@@ -3,7 +3,9 @@ package db_test
import ( import (
"testing" "testing"
"novamd/internal/db" "lemma/internal/db"
_ "lemma/internal/testenv"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )

View File

@@ -5,19 +5,20 @@ import (
"fmt" "fmt"
"time" "time"
"novamd/internal/models" "lemma/internal/models"
) )
// CreateSession inserts a new session record into the database // CreateSession inserts a new session record into the database
func (db *database) CreateSession(session *models.Session) error { func (db *database) CreateSession(session *models.Session) error {
_, err := db.Exec(` _, err := db.Exec(`
INSERT INTO sessions (id, user_id, refresh_token, expires_at, created_at) INSERT INTO sessions (id, user_id, refresh_token, expires_at, created_at)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
session.ID, session.UserID, session.RefreshToken, session.ExpiresAt, session.CreatedAt, session.ID, session.UserID, session.RefreshToken, session.ExpiresAt, session.CreatedAt,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to store session: %w", err) return fmt.Errorf("failed to store session: %w", err)
} }
return nil return nil
} }
@@ -41,6 +42,26 @@ func (db *database) GetSessionByRefreshToken(refreshToken string) (*models.Sessi
return session, nil return session, nil
} }
// GetSessionByID retrieves a session by its ID
func (db *database) GetSessionByID(sessionID string) (*models.Session, error) {
session := &models.Session{}
err := db.QueryRow(`
SELECT id, user_id, refresh_token, expires_at, created_at
FROM sessions
WHERE id = ? AND expires_at > ?`,
sessionID, time.Now(),
).Scan(&session.ID, &session.UserID, &session.RefreshToken, &session.ExpiresAt, &session.CreatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("session not found")
}
if err != nil {
return nil, fmt.Errorf("failed to fetch session: %w", err)
}
return session, nil
}
// DeleteSession removes a session from the database // DeleteSession removes a session from the database
func (db *database) DeleteSession(sessionID string) error { func (db *database) DeleteSession(sessionID string) error {
result, err := db.Exec("DELETE FROM sessions WHERE id = ?", sessionID) result, err := db.Exec("DELETE FROM sessions WHERE id = ?", sessionID)
@@ -62,9 +83,17 @@ func (db *database) DeleteSession(sessionID string) error {
// CleanExpiredSessions removes all expired sessions from the database // CleanExpiredSessions removes all expired sessions from the database
func (db *database) CleanExpiredSessions() error { func (db *database) CleanExpiredSessions() error {
_, err := db.Exec("DELETE FROM sessions WHERE expires_at <= ?", time.Now()) log := getLogger().WithGroup("sessions")
result, err := db.Exec("DELETE FROM sessions WHERE expires_at <= ?", time.Now())
if err != nil { if err != nil {
return fmt.Errorf("failed to clean expired sessions: %w", err) return fmt.Errorf("failed to clean expired sessions: %w", err)
} }
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
log.Info("cleaned expired sessions", "sessions_removed", rowsAffected)
return nil return nil
} }

View File

@@ -5,8 +5,9 @@ import (
"testing" "testing"
"time" "time"
"novamd/internal/db" "lemma/internal/db"
"novamd/internal/models" "lemma/internal/models"
_ "lemma/internal/testenv"
"github.com/google/uuid" "github.com/google/uuid"
) )

View File

@@ -21,6 +21,8 @@ type UserStats struct {
// EnsureJWTSecret makes sure a JWT signing secret exists in the database // EnsureJWTSecret makes sure a JWT signing secret exists in the database
// If no secret exists, it generates and stores a new one // If no secret exists, it generates and stores a new one
func (db *database) EnsureJWTSecret() (string, error) { func (db *database) EnsureJWTSecret() (string, error) {
log := getLogger().WithGroup("system")
// First, try to get existing secret // First, try to get existing secret
secret, err := db.GetSystemSetting(JWTSecretKey) secret, err := db.GetSystemSetting(JWTSecretKey)
if err == nil { if err == nil {
@@ -39,6 +41,8 @@ func (db *database) EnsureJWTSecret() (string, error) {
return "", fmt.Errorf("failed to store JWT secret: %w", err) return "", fmt.Errorf("failed to store JWT secret: %w", err)
} }
log.Info("new JWT secret generated and stored")
return newSecret, nil return newSecret, nil
} }
@@ -49,27 +53,38 @@ func (db *database) GetSystemSetting(key string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
return value, nil return value, nil
} }
// SetSystemSetting stores or updates a system setting // SetSystemSetting stores or updates a system setting
func (db *database) SetSystemSetting(key, value string) error { func (db *database) SetSystemSetting(key, value string) error {
_, err := db.Exec(` _, err := db.Exec(`
INSERT INTO system_settings (key, value) INSERT INTO system_settings (key, value)
VALUES (?, ?) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = ?`, ON CONFLICT(key) DO UPDATE SET value = ?`,
key, value, value) key, value, value)
return err
if err != nil {
return fmt.Errorf("failed to store system setting: %w", err)
}
return nil
} }
// generateRandomSecret generates a cryptographically secure random string // generateRandomSecret generates a cryptographically secure random string
func generateRandomSecret(bytes int) (string, error) { func generateRandomSecret(bytes int) (string, error) {
log := getLogger().WithGroup("system")
log.Debug("generating random secret", "bytes", bytes)
b := make([]byte, bytes) b := make([]byte, bytes)
_, err := rand.Read(b) _, err := rand.Read(b)
if err != nil { if err != nil {
return "", err return "", fmt.Errorf("failed to generate random bytes: %w", err)
} }
return base64.StdEncoding.EncodeToString(b), nil
secret := base64.StdEncoding.EncodeToString(b)
return secret, nil
} }
// GetSystemStats returns system-wide statistics // GetSystemStats returns system-wide statistics
@@ -79,24 +94,23 @@ func (db *database) GetSystemStats() (*UserStats, error) {
// Get total users // Get total users
err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&stats.TotalUsers) err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&stats.TotalUsers)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to get total users count: %w", err)
} }
// Get total workspaces // Get total workspaces
err = db.QueryRow("SELECT COUNT(*) FROM workspaces").Scan(&stats.TotalWorkspaces) err = db.QueryRow("SELECT COUNT(*) FROM workspaces").Scan(&stats.TotalWorkspaces)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to get total workspaces count: %w", err)
} }
// Get active users (users with activity in last 30 days) // Get active users (users with activity in last 30 days)
err = db.QueryRow(` err = db.QueryRow(`
SELECT COUNT(DISTINCT user_id) SELECT COUNT(DISTINCT user_id)
FROM sessions FROM sessions
WHERE created_at > datetime('now', '-30 days')`). WHERE created_at > datetime('now', '-30 days')`).
Scan(&stats.ActiveUsers) Scan(&stats.ActiveUsers)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to get active users count: %w", err)
} }
return stats, nil return stats, nil
} }

View File

@@ -7,8 +7,9 @@ import (
"testing" "testing"
"time" "time"
"novamd/internal/db" "lemma/internal/db"
"novamd/internal/models" "lemma/internal/models"
_ "lemma/internal/testenv"
"github.com/google/uuid" "github.com/google/uuid"
) )

View File

@@ -4,7 +4,7 @@ package db
import ( import (
"database/sql" "database/sql"
"novamd/internal/secrets" "lemma/internal/secrets"
) )
type TestDatabase interface { type TestDatabase interface {

View File

@@ -2,35 +2,39 @@ package db
import ( import (
"database/sql" "database/sql"
"novamd/internal/models" "fmt"
"lemma/internal/models"
) )
// CreateUser inserts a new user record into the database // CreateUser inserts a new user record into the database
func (db *database) CreateUser(user *models.User) (*models.User, error) { func (db *database) CreateUser(user *models.User) (*models.User, error) {
log := getLogger().WithGroup("users")
log.Debug("creating user", "email", user.Email)
tx, err := db.Begin() tx, err := db.Begin()
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to begin transaction: %w", err)
} }
defer tx.Rollback() defer tx.Rollback()
result, err := tx.Exec(` result, err := tx.Exec(`
INSERT INTO users (email, display_name, password_hash, role) INSERT INTO users (email, display_name, password_hash, role)
VALUES (?, ?, ?, ?)`, VALUES (?, ?, ?, ?)`,
user.Email, user.DisplayName, user.PasswordHash, user.Role) user.Email, user.DisplayName, user.PasswordHash, user.Role)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to insert user: %w", err)
} }
userID, err := result.LastInsertId() userID, err := result.LastInsertId()
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to get last insert ID: %w", err)
} }
user.ID = int(userID) user.ID = int(userID)
// Retrieve the created_at timestamp // Retrieve the created_at timestamp
err = tx.QueryRow("SELECT created_at FROM users WHERE id = ?", user.ID).Scan(&user.CreatedAt) err = tx.QueryRow("SELECT created_at FROM users WHERE id = ?", user.ID).Scan(&user.CreatedAt)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to get created timestamp: %w", err)
} }
// Create default workspace with default settings // Create default workspace with default settings
@@ -38,39 +42,42 @@ func (db *database) CreateUser(user *models.User) (*models.User, error) {
UserID: user.ID, UserID: user.ID,
Name: "Main", Name: "Main",
} }
defaultWorkspace.SetDefaultSettings() // Initialize default settings defaultWorkspace.SetDefaultSettings()
// Create workspace with settings // Create workspace with settings
err = db.createWorkspaceTx(tx, defaultWorkspace) err = db.createWorkspaceTx(tx, defaultWorkspace)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to create default workspace: %w", err)
} }
// Update user's last workspace ID // Update user's last workspace ID
_, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", defaultWorkspace.ID, user.ID) _, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", defaultWorkspace.ID, user.ID)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to update last workspace ID: %w", err)
} }
err = tx.Commit() err = tx.Commit()
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to commit transaction: %w", err)
} }
log.Debug("created user", "user_id", user.ID)
user.LastWorkspaceID = defaultWorkspace.ID user.LastWorkspaceID = defaultWorkspace.ID
return user, nil return user, nil
} }
// Helper function to create a workspace in a transaction // Helper function to create a workspace in a transaction
func (db *database) createWorkspaceTx(tx *sql.Tx, workspace *models.Workspace) error { func (db *database) createWorkspaceTx(tx *sql.Tx, workspace *models.Workspace) error {
log := getLogger().WithGroup("users")
result, err := tx.Exec(` result, err := tx.Exec(`
INSERT INTO workspaces ( INSERT INTO workspaces (
user_id, name, user_id, name,
theme, auto_save, show_hidden_files, theme, auto_save, show_hidden_files,
git_enabled, git_url, git_user, git_token, git_enabled, git_url, git_user, git_token,
git_auto_commit, git_commit_msg_template, git_auto_commit, git_commit_msg_template,
git_commit_name, git_commit_email git_commit_name, git_commit_email
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
workspace.UserID, workspace.Name, workspace.UserID, workspace.Name,
workspace.Theme, workspace.AutoSave, workspace.ShowHiddenFiles, workspace.Theme, workspace.AutoSave, workspace.ShowHiddenFiles,
workspace.GitEnabled, workspace.GitURL, workspace.GitUser, workspace.GitToken, workspace.GitEnabled, workspace.GitURL, workspace.GitUser, workspace.GitToken,
@@ -78,17 +85,21 @@ func (db *database) createWorkspaceTx(tx *sql.Tx, workspace *models.Workspace) e
workspace.GitCommitName, workspace.GitCommitEmail, workspace.GitCommitName, workspace.GitCommitEmail,
) )
if err != nil { if err != nil {
return err return fmt.Errorf("failed to insert workspace: %w", err)
} }
id, err := result.LastInsertId() id, err := result.LastInsertId()
if err != nil { if err != nil {
return err return fmt.Errorf("failed to get workspace ID: %w", err)
} }
workspace.ID = int(id) workspace.ID = int(id)
log.Debug("created user workspace",
"workspace_id", workspace.ID,
"user_id", workspace.UserID)
return nil return nil
} }
// GetUserByID retrieves a user by ID
func (db *database) GetUserByID(id int) (*models.User, error) { func (db *database) GetUserByID(id int) (*models.User, error) {
user := &models.User{} user := &models.User{}
err := db.QueryRow(` err := db.QueryRow(`
@@ -97,15 +108,18 @@ func (db *database) GetUserByID(id int) (*models.User, error) {
last_workspace_id last_workspace_id
FROM users FROM users
WHERE id = ?`, id). WHERE id = ?`, id).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &user.Role, &user.CreatedAt, Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash,
&user.LastWorkspaceID) &user.Role, &user.CreatedAt, &user.LastWorkspaceID)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("user not found")
}
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to fetch user: %w", err)
} }
return user, nil return user, nil
} }
// GetUserByEmail retrieves a user by email
func (db *database) GetUserByEmail(email string) (*models.User, error) { func (db *database) GetUserByEmail(email string) (*models.User, error) {
user := &models.User{} user := &models.User{}
err := db.QueryRow(` err := db.QueryRow(`
@@ -114,35 +128,52 @@ func (db *database) GetUserByEmail(email string) (*models.User, error) {
last_workspace_id last_workspace_id
FROM users FROM users
WHERE email = ?`, email). WHERE email = ?`, email).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &user.Role, &user.CreatedAt, Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash,
&user.LastWorkspaceID) &user.Role, &user.CreatedAt, &user.LastWorkspaceID)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("user not found")
}
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to fetch user: %w", err)
} }
return user, nil return user, nil
} }
// UpdateUser updates a user's information
func (db *database) UpdateUser(user *models.User) error { func (db *database) UpdateUser(user *models.User) error {
_, err := db.Exec(` result, err := db.Exec(`
UPDATE users UPDATE users
SET email = ?, display_name = ?, password_hash = ?, role = ?, last_workspace_id = ? SET email = ?, display_name = ?, password_hash = ?, role = ?, last_workspace_id = ?
WHERE id = ?`, WHERE id = ?`,
user.Email, user.DisplayName, user.PasswordHash, user.Role, user.LastWorkspaceID, user.ID) user.Email, user.DisplayName, user.PasswordHash, user.Role,
return err user.LastWorkspaceID, user.ID)
if err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("user not found")
}
return nil
} }
// GetAllUsers returns a list of all users in the system
func (db *database) GetAllUsers() ([]*models.User, error) { func (db *database) GetAllUsers() ([]*models.User, error) {
rows, err := db.Query(` rows, err := db.Query(`
SELECT SELECT
id, email, display_name, role, created_at, id, email, display_name, role, created_at,
last_workspace_id last_workspace_id
FROM users FROM users
ORDER BY id ASC`) ORDER BY id ASC`)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to query users: %w", err)
} }
defer rows.Close() defer rows.Close()
@@ -154,60 +185,74 @@ func (db *database) GetAllUsers() ([]*models.User, error) {
&user.CreatedAt, &user.LastWorkspaceID, &user.CreatedAt, &user.LastWorkspaceID,
) )
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to scan user row: %w", err)
} }
users = append(users, user) users = append(users, user)
} }
return users, nil return users, nil
} }
// UpdateLastWorkspace updates the last workspace the user accessed
func (db *database) UpdateLastWorkspace(userID int, workspaceName string) error { func (db *database) UpdateLastWorkspace(userID int, workspaceName string) error {
tx, err := db.Begin() tx, err := db.Begin()
if err != nil { if err != nil {
return err return fmt.Errorf("failed to begin transaction: %w", err)
} }
defer tx.Rollback() defer tx.Rollback()
var workspaceID int var workspaceID int
err = tx.QueryRow("SELECT id FROM workspaces WHERE user_id = ? AND name = ?",
err = tx.QueryRow("SELECT id FROM workspaces WHERE user_id = ? AND name = ?", userID, workspaceName).Scan(&workspaceID) userID, workspaceName).Scan(&workspaceID)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to find workspace: %w", err)
} }
_, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID) _, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?",
workspaceID, userID)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to update last workspace: %w", err)
} }
return tx.Commit() err = tx.Commit()
if err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
} }
// DeleteUser deletes a user and all their workspaces
func (db *database) DeleteUser(id int) error { func (db *database) DeleteUser(id int) error {
log := getLogger().WithGroup("users")
log.Debug("deleting user", "user_id", id)
tx, err := db.Begin() tx, err := db.Begin()
if err != nil { if err != nil {
return err return fmt.Errorf("failed to begin transaction: %w", err)
} }
defer tx.Rollback() defer tx.Rollback()
// Delete all user's workspaces first // Delete all user's workspaces first
log.Debug("deleting user workspaces", "user_id", id)
_, err = tx.Exec("DELETE FROM workspaces WHERE user_id = ?", id) _, err = tx.Exec("DELETE FROM workspaces WHERE user_id = ?", id)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to delete workspaces: %w", err)
} }
// Delete the user // Delete the user
_, err = tx.Exec("DELETE FROM users WHERE id = ?", id) _, err = tx.Exec("DELETE FROM users WHERE id = ?", id)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to delete user: %w", err)
} }
return tx.Commit() err = tx.Commit()
if err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
log.Debug("deleted user", "user_id", id)
return nil
} }
// GetLastWorkspaceName returns the name of the last workspace the user accessed
func (db *database) GetLastWorkspaceName(userID int) (string, error) { func (db *database) GetLastWorkspaceName(userID int) (string, error) {
var workspaceName string var workspaceName string
err := db.QueryRow(` err := db.QueryRow(`
@@ -217,12 +262,24 @@ func (db *database) GetLastWorkspaceName(userID int) (string, error) {
JOIN users u ON u.last_workspace_id = w.id JOIN users u ON u.last_workspace_id = w.id
WHERE u.id = ?`, userID). WHERE u.id = ?`, userID).
Scan(&workspaceName) Scan(&workspaceName)
return workspaceName, err
if err == sql.ErrNoRows {
return "", fmt.Errorf("no last workspace found")
}
if err != nil {
return "", fmt.Errorf("failed to fetch last workspace name: %w", err)
}
return workspaceName, nil
} }
// CountAdminUsers returns the number of admin users in the system // CountAdminUsers returns the number of admin users in the system
func (db *database) CountAdminUsers() (int, error) { func (db *database) CountAdminUsers() (int, error) {
var count int var count int
err := db.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'admin'").Scan(&count) err := db.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'admin'").Scan(&count)
return count, err if err != nil {
return 0, fmt.Errorf("failed to count admin users: %w", err)
}
return count, nil
} }

View File

@@ -4,8 +4,9 @@ import (
"strings" "strings"
"testing" "testing"
"novamd/internal/db" "lemma/internal/db"
"novamd/internal/models" "lemma/internal/models"
_ "lemma/internal/testenv"
) )
func TestUserOperations(t *testing.T) { func TestUserOperations(t *testing.T) {

View File

@@ -3,11 +3,17 @@ package db
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"novamd/internal/models" "lemma/internal/models"
) )
// CreateWorkspace inserts a new workspace record into the database // CreateWorkspace inserts a new workspace record into the database
func (db *database) CreateWorkspace(workspace *models.Workspace) error { func (db *database) CreateWorkspace(workspace *models.Workspace) error {
log := getLogger().WithGroup("workspaces")
log.Debug("creating new workspace",
"user_id", workspace.UserID,
"name", workspace.Name,
"git_enabled", workspace.GitEnabled)
// Set default settings if not provided // Set default settings if not provided
if workspace.Theme == "" { if workspace.Theme == "" {
workspace.SetDefaultSettings() workspace.SetDefaultSettings()
@@ -20,25 +26,26 @@ func (db *database) CreateWorkspace(workspace *models.Workspace) error {
} }
result, err := db.Exec(` result, err := db.Exec(`
INSERT INTO workspaces ( INSERT INTO workspaces (
user_id, name, theme, auto_save, show_hidden_files, user_id, name, theme, auto_save, show_hidden_files,
git_enabled, git_url, git_user, git_token, git_enabled, git_url, git_user, git_token,
git_auto_commit, git_commit_msg_template, git_auto_commit, git_commit_msg_template,
git_commit_name, git_commit_email git_commit_name, git_commit_email
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
workspace.UserID, workspace.Name, workspace.Theme, workspace.AutoSave, workspace.ShowHiddenFiles, workspace.UserID, workspace.Name, workspace.Theme, workspace.AutoSave, workspace.ShowHiddenFiles,
workspace.GitEnabled, workspace.GitURL, workspace.GitUser, encryptedToken, workspace.GitEnabled, workspace.GitURL, workspace.GitUser, encryptedToken,
workspace.GitAutoCommit, workspace.GitCommitMsgTemplate, workspace.GitCommitName, workspace.GitCommitEmail, workspace.GitAutoCommit, workspace.GitCommitMsgTemplate, workspace.GitCommitName, workspace.GitCommitEmail,
) )
if err != nil { if err != nil {
return err return fmt.Errorf("failed to insert workspace: %w", err)
} }
id, err := result.LastInsertId() id, err := result.LastInsertId()
if err != nil { if err != nil {
return err return fmt.Errorf("failed to get workspace ID: %w", err)
} }
workspace.ID = int(id) workspace.ID = int(id)
return nil return nil
} }
@@ -48,23 +55,28 @@ func (db *database) GetWorkspaceByID(id int) (*models.Workspace, error) {
var encryptedToken string var encryptedToken string
err := db.QueryRow(` err := db.QueryRow(`
SELECT SELECT
id, user_id, name, created_at, id, user_id, name, created_at,
theme, auto_save, show_hidden_files, theme, auto_save, show_hidden_files,
git_enabled, git_url, git_user, git_token, git_enabled, git_url, git_user, git_token,
git_auto_commit, git_commit_msg_template, git_auto_commit, git_commit_msg_template,
git_commit_name, git_commit_email git_commit_name, git_commit_email
FROM workspaces FROM workspaces
WHERE id = ?`, WHERE id = ?`,
id, id,
).Scan( ).Scan(
&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt, &workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt,
&workspace.Theme, &workspace.AutoSave, &workspace.ShowHiddenFiles, &workspace.Theme, &workspace.AutoSave, &workspace.ShowHiddenFiles,
&workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &encryptedToken, &workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &encryptedToken,
&workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate, &workspace.GitCommitName, &workspace.GitCommitEmail, &workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate,
&workspace.GitCommitName, &workspace.GitCommitEmail,
) )
if err == sql.ErrNoRows {
return nil, fmt.Errorf("workspace not found")
}
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to fetch workspace: %w", err)
} }
// Decrypt token // Decrypt token
@@ -82,14 +94,14 @@ func (db *database) GetWorkspaceByName(userID int, workspaceName string) (*model
var encryptedToken string var encryptedToken string
err := db.QueryRow(` err := db.QueryRow(`
SELECT SELECT
id, user_id, name, created_at, id, user_id, name, created_at,
theme, auto_save, show_hidden_files, theme, auto_save, show_hidden_files,
git_enabled, git_url, git_user, git_token, git_enabled, git_url, git_user, git_token,
git_auto_commit, git_commit_msg_template, git_auto_commit, git_commit_msg_template,
git_commit_name, git_commit_email git_commit_name, git_commit_email
FROM workspaces FROM workspaces
WHERE user_id = ? AND name = ?`, WHERE user_id = ? AND name = ?`,
userID, workspaceName, userID, workspaceName,
).Scan( ).Scan(
&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt, &workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt,
@@ -98,8 +110,12 @@ func (db *database) GetWorkspaceByName(userID int, workspaceName string) (*model
&workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate, &workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate,
&workspace.GitCommitName, &workspace.GitCommitEmail, &workspace.GitCommitName, &workspace.GitCommitEmail,
) )
if err == sql.ErrNoRows {
return nil, fmt.Errorf("workspace not found")
}
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to fetch workspace: %w", err)
} }
// Decrypt token // Decrypt token
@@ -120,21 +136,21 @@ func (db *database) UpdateWorkspace(workspace *models.Workspace) error {
} }
_, err = db.Exec(` _, err = db.Exec(`
UPDATE workspaces UPDATE workspaces
SET SET
name = ?, name = ?,
theme = ?, theme = ?,
auto_save = ?, auto_save = ?,
show_hidden_files = ?, show_hidden_files = ?,
git_enabled = ?, git_enabled = ?,
git_url = ?, git_url = ?,
git_user = ?, git_user = ?,
git_token = ?, git_token = ?,
git_auto_commit = ?, git_auto_commit = ?,
git_commit_msg_template = ?, git_commit_msg_template = ?,
git_commit_name = ?, git_commit_name = ?,
git_commit_email = ? git_commit_email = ?
WHERE id = ? AND user_id = ?`, WHERE id = ? AND user_id = ?`,
workspace.Name, workspace.Name,
workspace.Theme, workspace.Theme,
workspace.AutoSave, workspace.AutoSave,
@@ -150,24 +166,28 @@ func (db *database) UpdateWorkspace(workspace *models.Workspace) error {
workspace.ID, workspace.ID,
workspace.UserID, workspace.UserID,
) )
return err if err != nil {
return fmt.Errorf("failed to update workspace: %w", err)
}
return nil
} }
// GetWorkspacesByUserID retrieves all workspaces for a user // GetWorkspacesByUserID retrieves all workspaces for a user
func (db *database) GetWorkspacesByUserID(userID int) ([]*models.Workspace, error) { func (db *database) GetWorkspacesByUserID(userID int) ([]*models.Workspace, error) {
rows, err := db.Query(` rows, err := db.Query(`
SELECT SELECT
id, user_id, name, created_at, id, user_id, name, created_at,
theme, auto_save, show_hidden_files, theme, auto_save, show_hidden_files,
git_enabled, git_url, git_user, git_token, git_enabled, git_url, git_user, git_token,
git_auto_commit, git_commit_msg_template, git_auto_commit, git_commit_msg_template,
git_commit_name, git_commit_email git_commit_name, git_commit_email
FROM workspaces FROM workspaces
WHERE user_id = ?`, WHERE user_id = ?`,
userID, userID,
) )
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to query workspaces: %w", err)
} }
defer rows.Close() defer rows.Close()
@@ -183,7 +203,7 @@ func (db *database) GetWorkspacesByUserID(userID int) ([]*models.Workspace, erro
&workspace.GitCommitName, &workspace.GitCommitEmail, &workspace.GitCommitName, &workspace.GitCommitEmail,
) )
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to scan workspace row: %w", err)
} }
// Decrypt token // Decrypt token
@@ -194,27 +214,31 @@ func (db *database) GetWorkspacesByUserID(userID int) ([]*models.Workspace, erro
workspaces = append(workspaces, workspace) workspaces = append(workspaces, workspace)
} }
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating workspace rows: %w", err)
}
return workspaces, nil return workspaces, nil
} }
// UpdateWorkspaceSettings updates only the settings portion of a workspace // UpdateWorkspaceSettings updates only the settings portion of a workspace
// This is useful when you don't want to modify the name or other core workspace properties
func (db *database) UpdateWorkspaceSettings(workspace *models.Workspace) error { func (db *database) UpdateWorkspaceSettings(workspace *models.Workspace) error {
_, err := db.Exec(` _, err := db.Exec(`
UPDATE workspaces UPDATE workspaces
SET SET
theme = ?, theme = ?,
auto_save = ?, auto_save = ?,
show_hidden_files = ?, show_hidden_files = ?,
git_enabled = ?, git_enabled = ?,
git_url = ?, git_url = ?,
git_user = ?, git_user = ?,
git_token = ?, git_token = ?,
git_auto_commit = ?, git_auto_commit = ?,
git_commit_msg_template = ?, git_commit_msg_template = ?,
git_commit_name = ?, git_commit_name = ?,
git_commit_email = ? git_commit_email = ?
WHERE id = ?`, WHERE id = ?`,
workspace.Theme, workspace.Theme,
workspace.AutoSave, workspace.AutoSave,
workspace.ShowHiddenFiles, workspace.ShowHiddenFiles,
@@ -228,59 +252,104 @@ func (db *database) UpdateWorkspaceSettings(workspace *models.Workspace) error {
workspace.GitCommitEmail, workspace.GitCommitEmail,
workspace.ID, workspace.ID,
) )
return err if err != nil {
return fmt.Errorf("failed to update workspace settings: %w", err)
}
return nil
} }
// DeleteWorkspace removes a workspace record from the database // DeleteWorkspace removes a workspace record from the database
func (db *database) DeleteWorkspace(id int) error { func (db *database) DeleteWorkspace(id int) error {
log := getLogger().WithGroup("workspaces")
_, err := db.Exec("DELETE FROM workspaces WHERE id = ?", id) _, err := db.Exec("DELETE FROM workspaces WHERE id = ?", id)
return err if err != nil {
return fmt.Errorf("failed to delete workspace: %w", err)
}
log.Debug("workspace deleted", "workspace_id", id)
return nil
} }
// DeleteWorkspaceTx removes a workspace record from the database within a transaction // DeleteWorkspaceTx removes a workspace record from the database within a transaction
func (db *database) DeleteWorkspaceTx(tx *sql.Tx, id int) error { func (db *database) DeleteWorkspaceTx(tx *sql.Tx, id int) error {
_, err := tx.Exec("DELETE FROM workspaces WHERE id = ?", id) log := getLogger().WithGroup("workspaces")
return err result, err := tx.Exec("DELETE FROM workspaces WHERE id = ?", id)
if err != nil {
return fmt.Errorf("failed to delete workspace in transaction: %w", err)
}
_, err = result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected in transaction: %w", err)
}
log.Debug("workspace deleted",
"workspace_id", id)
return nil
} }
// UpdateLastWorkspaceTx sets the last workspace for a user in with a transaction // UpdateLastWorkspaceTx sets the last workspace for a user in a transaction
func (db *database) UpdateLastWorkspaceTx(tx *sql.Tx, userID, workspaceID int) error { func (db *database) UpdateLastWorkspaceTx(tx *sql.Tx, userID, workspaceID int) error {
_, err := tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID) result, err := tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?",
return err workspaceID, userID)
if err != nil {
return fmt.Errorf("failed to update last workspace in transaction: %w", err)
}
_, err = result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected in transaction: %w", err)
}
return nil
} }
// UpdateLastOpenedFile updates the last opened file path for a workspace // UpdateLastOpenedFile updates the last opened file path for a workspace
func (db *database) UpdateLastOpenedFile(workspaceID int, filePath string) error { func (db *database) UpdateLastOpenedFile(workspaceID int, filePath string) error {
_, err := db.Exec("UPDATE workspaces SET last_opened_file_path = ? WHERE id = ?", filePath, workspaceID) _, err := db.Exec("UPDATE workspaces SET last_opened_file_path = ? WHERE id = ?",
return err filePath, workspaceID)
if err != nil {
return fmt.Errorf("failed to update last opened file: %w", err)
}
return nil
} }
// GetLastOpenedFile retrieves the last opened file path for a workspace // GetLastOpenedFile retrieves the last opened file path for a workspace
func (db *database) GetLastOpenedFile(workspaceID int) (string, error) { func (db *database) GetLastOpenedFile(workspaceID int) (string, error) {
var filePath sql.NullString var filePath sql.NullString
err := db.QueryRow("SELECT last_opened_file_path FROM workspaces WHERE id = ?", workspaceID).Scan(&filePath) err := db.QueryRow("SELECT last_opened_file_path FROM workspaces WHERE id = ?",
if err != nil { workspaceID).Scan(&filePath)
return "", err
if err == sql.ErrNoRows {
return "", fmt.Errorf("workspace not found")
} }
if err != nil {
return "", fmt.Errorf("failed to fetch last opened file: %w", err)
}
if !filePath.Valid { if !filePath.Valid {
return "", nil return "", nil
} }
return filePath.String, nil return filePath.String, nil
} }
// GetAllWorkspaces retrieves all workspaces in the database // GetAllWorkspaces retrieves all workspaces in the database
func (db *database) GetAllWorkspaces() ([]*models.Workspace, error) { func (db *database) GetAllWorkspaces() ([]*models.Workspace, error) {
rows, err := db.Query(` rows, err := db.Query(`
SELECT SELECT
id, user_id, name, created_at, id, user_id, name, created_at,
theme, auto_save, show_hidden_files, theme, auto_save, show_hidden_files,
git_enabled, git_url, git_user, git_token, git_enabled, git_url, git_user, git_token,
git_auto_commit, git_commit_msg_template, git_auto_commit, git_commit_msg_template,
git_commit_name, git_commit_email git_commit_name, git_commit_email
FROM workspaces`, FROM workspaces`,
) )
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to query workspaces: %w", err)
} }
defer rows.Close() defer rows.Close()
@@ -296,7 +365,7 @@ func (db *database) GetAllWorkspaces() ([]*models.Workspace, error) {
&workspace.GitCommitName, &workspace.GitCommitEmail, &workspace.GitCommitName, &workspace.GitCommitEmail,
) )
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to scan workspace row: %w", err)
} }
// Decrypt token // Decrypt token
@@ -307,5 +376,10 @@ func (db *database) GetAllWorkspaces() ([]*models.Workspace, error) {
workspaces = append(workspaces, workspace) workspaces = append(workspaces, workspace)
} }
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating workspace rows: %w", err)
}
return workspaces, nil return workspaces, nil
} }

View File

@@ -1,12 +1,12 @@
package db_test package db_test
import ( import (
"database/sql"
"strings" "strings"
"testing" "testing"
"novamd/internal/db" "lemma/internal/db"
"novamd/internal/models" "lemma/internal/models"
_ "lemma/internal/testenv"
) )
func TestWorkspaceOperations(t *testing.T) { func TestWorkspaceOperations(t *testing.T) {
@@ -385,8 +385,8 @@ func TestWorkspaceOperations(t *testing.T) {
// Verify workspace is gone // Verify workspace is gone
_, err = database.GetWorkspaceByID(workspace.ID) _, err = database.GetWorkspaceByID(workspace.ID)
if err != sql.ErrNoRows { if !strings.Contains(err.Error(), "workspace not found") {
t.Errorf("expected sql.ErrNoRows, got %v", err) t.Errorf("expected workspace not found, got %v", err)
} }
}) })
} }

View File

@@ -7,7 +7,10 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"lemma/internal/logging"
"github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/plumbing/transport/http"
) )
@@ -26,17 +29,34 @@ type Config struct {
type Client interface { type Client interface {
Clone() error Clone() error
Pull() error Pull() error
Commit(message string) error Commit(message string) (CommitHash, error)
Push() error Push() error
EnsureRepo() error EnsureRepo() error
} }
// CommitHash represents a Git commit hash
type CommitHash plumbing.Hash
// String returns the string representation of the CommitHash
func (h CommitHash) String() string {
return plumbing.Hash(h).String()
}
// client implements the Client interface // client implements the Client interface
type client struct { type client struct {
Config Config
repo *git.Repository repo *git.Repository
} }
var logger logging.Logger
func getLogger() logging.Logger {
if logger == nil {
logger = logging.WithGroup("git")
}
return logger
}
// New creates a new git Client instance // New creates a new git Client instance
func New(url, username, token, workDir, commitName, commitEmail string) Client { func New(url, username, token, workDir, commitName, commitEmail string) Client {
return &client{ return &client{
@@ -53,6 +73,11 @@ func New(url, username, token, workDir, commitName, commitEmail string) Client {
// Clone clones the Git repository to the local directory // Clone clones the Git repository to the local directory
func (c *client) Clone() error { func (c *client) Clone() error {
log := getLogger()
log.Info("cloning git repository",
"url", c.URL,
"workDir", c.WorkDir)
auth := &http.BasicAuth{ auth := &http.BasicAuth{
Username: c.Username, Username: c.Username,
Password: c.Token, Password: c.Token,
@@ -64,7 +89,6 @@ func (c *client) Clone() error {
Auth: auth, Auth: auth,
Progress: os.Stdout, Progress: os.Stdout,
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to clone repository: %w", err) return fmt.Errorf("failed to clone repository: %w", err)
} }
@@ -74,6 +98,10 @@ func (c *client) Clone() error {
// Pull pulls the latest changes from the remote repository // Pull pulls the latest changes from the remote repository
func (c *client) Pull() error { func (c *client) Pull() error {
log := getLogger().With(
"workDir", c.WorkDir,
)
if c.repo == nil { if c.repo == nil {
return fmt.Errorf("repository not initialized") return fmt.Errorf("repository not initialized")
} }
@@ -92,31 +120,40 @@ func (c *client) Pull() error {
Auth: auth, Auth: auth,
Progress: os.Stdout, Progress: os.Stdout,
}) })
if err != nil && err != git.NoErrAlreadyUpToDate { if err != nil && err != git.NoErrAlreadyUpToDate {
return fmt.Errorf("failed to pull changes: %w", err) return fmt.Errorf("failed to pull changes: %w", err)
} }
if err == git.NoErrAlreadyUpToDate {
log.Debug("repository already up to date")
} else {
log.Debug("pulled latest changes")
}
return nil return nil
} }
// Commit commits the changes in the repository with the given message // Commit commits the changes in the repository with the given message
func (c *client) Commit(message string) error { func (c *client) Commit(message string) (CommitHash, error) {
log := getLogger().With(
"workDir", c.WorkDir,
)
if c.repo == nil { if c.repo == nil {
return fmt.Errorf("repository not initialized") return CommitHash(plumbing.ZeroHash), fmt.Errorf("repository not initialized")
} }
w, err := c.repo.Worktree() w, err := c.repo.Worktree()
if err != nil { if err != nil {
return fmt.Errorf("failed to get worktree: %w", err) return CommitHash(plumbing.ZeroHash), fmt.Errorf("failed to get worktree: %w", err)
} }
_, err = w.Add(".") _, err = w.Add(".")
if err != nil { if err != nil {
return fmt.Errorf("failed to add changes: %w", err) return CommitHash(plumbing.ZeroHash), fmt.Errorf("failed to add changes: %w", err)
} }
_, err = w.Commit(message, &git.CommitOptions{ hash, err := w.Commit(message, &git.CommitOptions{
Author: &object.Signature{ Author: &object.Signature{
Name: c.CommitName, Name: c.CommitName,
Email: c.CommitEmail, Email: c.CommitEmail,
@@ -124,14 +161,19 @@ func (c *client) Commit(message string) error {
}, },
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to commit changes: %w", err) return CommitHash(plumbing.ZeroHash), fmt.Errorf("failed to commit changes: %w", err)
} }
return nil log.Debug("changes committed")
return CommitHash(hash), nil
} }
// Push pushes the changes to the remote repository // Push pushes the changes to the remote repository
func (c *client) Push() error { func (c *client) Push() error {
log := getLogger().With(
"workDir", c.WorkDir,
)
if c.repo == nil { if c.repo == nil {
return fmt.Errorf("repository not initialized") return fmt.Errorf("repository not initialized")
} }
@@ -145,17 +187,30 @@ func (c *client) Push() error {
Auth: auth, Auth: auth,
Progress: os.Stdout, Progress: os.Stdout,
}) })
if err != nil && err != git.NoErrAlreadyUpToDate { if err != nil && err != git.NoErrAlreadyUpToDate {
return fmt.Errorf("failed to push changes: %w", err) return fmt.Errorf("failed to push changes: %w", err)
} }
if err == git.NoErrAlreadyUpToDate {
log.Debug("remote already up to date",
"workDir", c.WorkDir)
} else {
log.Debug("pushed repository changes",
"workDir", c.WorkDir)
}
return nil return nil
} }
// EnsureRepo ensures the local repository is cloned and up-to-date // EnsureRepo ensures the local repository is cloned and up-to-date
func (c *client) EnsureRepo() error { func (c *client) EnsureRepo() error {
log := getLogger().With(
"workDir", c.WorkDir,
)
log.Debug("ensuring repository exists and is up to date")
if _, err := os.Stat(filepath.Join(c.WorkDir, ".git")); os.IsNotExist(err) { if _, err := os.Stat(filepath.Join(c.WorkDir, ".git")); os.IsNotExist(err) {
log.Info("repository not found, initiating clone")
return c.Clone() return c.Clone()
} }

View File

@@ -3,11 +3,12 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"lemma/internal/context"
"lemma/internal/db"
"lemma/internal/logging"
"lemma/internal/models"
"lemma/internal/storage"
"net/http" "net/http"
"novamd/internal/context"
"novamd/internal/db"
"novamd/internal/models"
"novamd/internal/storage"
"strconv" "strconv"
"time" "time"
@@ -31,189 +32,6 @@ type UpdateUserRequest struct {
Role models.UserRole `json:"role,omitempty"` Role models.UserRole `json:"role,omitempty"`
} }
// AdminListUsers returns a list of all users
func (h *Handler) AdminListUsers() http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
users, err := h.DB.GetAllUsers()
if err != nil {
http.Error(w, "Failed to list users", http.StatusInternalServerError)
return
}
respondJSON(w, users)
}
}
// AdminCreateUser creates a new user
func (h *Handler) AdminCreateUser() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate request
if req.Email == "" || req.Password == "" || req.Role == "" {
http.Error(w, "Email, password, and role are required", http.StatusBadRequest)
return
}
// Check if email already exists
existingUser, err := h.DB.GetUserByEmail(req.Email)
if err == nil && existingUser != nil {
http.Error(w, "Email already exists", http.StatusConflict)
return
}
// Check if password is long enough
if len(req.Password) < 8 {
http.Error(w, "Password must be at least 8 characters", http.StatusBadRequest)
return
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "Failed to hash password", http.StatusInternalServerError)
return
}
// Create user
user := &models.User{
Email: req.Email,
DisplayName: req.DisplayName,
PasswordHash: string(hashedPassword),
Role: req.Role,
}
insertedUser, err := h.DB.CreateUser(user)
if err != nil {
http.Error(w, "Failed to create user", http.StatusInternalServerError)
return
}
// Initialize user workspace
if err := h.Storage.InitializeUserWorkspace(insertedUser.ID, insertedUser.LastWorkspaceID); err != nil {
http.Error(w, "Failed to initialize user workspace", http.StatusInternalServerError)
return
}
respondJSON(w, insertedUser)
}
}
// AdminGetUser gets a specific user by ID
func (h *Handler) AdminGetUser() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := strconv.Atoi(chi.URLParam(r, "userId"))
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
user, err := h.DB.GetUserByID(userID)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
respondJSON(w, user)
}
}
// AdminUpdateUser updates a specific user
func (h *Handler) AdminUpdateUser() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := strconv.Atoi(chi.URLParam(r, "userId"))
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
// Get existing user
user, err := h.DB.GetUserByID(userID)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
var req UpdateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Update fields if provided
if req.Email != "" {
user.Email = req.Email
}
if req.DisplayName != "" {
user.DisplayName = req.DisplayName
}
if req.Role != "" {
user.Role = req.Role
}
if req.Password != "" {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "Failed to hash password", http.StatusInternalServerError)
return
}
user.PasswordHash = string(hashedPassword)
}
if err := h.DB.UpdateUser(user); err != nil {
http.Error(w, "Failed to update user", http.StatusInternalServerError)
return
}
respondJSON(w, user)
}
}
// AdminDeleteUser deletes a specific user
func (h *Handler) AdminDeleteUser() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r)
if !ok {
return
}
userID, err := strconv.Atoi(chi.URLParam(r, "userId"))
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
// Prevent admin from deleting themselves
if userID == ctx.UserID {
http.Error(w, "Cannot delete your own account", http.StatusBadRequest)
return
}
// Get user before deletion to check role
user, err := h.DB.GetUserByID(userID)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
// Prevent deletion of other admin users
if user.Role == models.RoleAdmin && ctx.UserID != userID {
http.Error(w, "Cannot delete other admin users", http.StatusForbidden)
return
}
if err := h.DB.DeleteUser(userID); err != nil {
http.Error(w, "Failed to delete user", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// WorkspaceStats holds workspace statistics // WorkspaceStats holds workspace statistics
type WorkspaceStats struct { type WorkspaceStats struct {
UserID int `json:"userID"` UserID int `json:"userID"`
@@ -224,24 +42,441 @@ type WorkspaceStats struct {
*storage.FileCountStats *storage.FileCountStats
} }
// AdminListWorkspaces returns a list of all workspaces and their stats // SystemStats holds system-wide statistics
type SystemStats struct {
*db.UserStats
*storage.FileCountStats
}
func getAdminLogger() logging.Logger {
return getHandlersLogger().WithGroup("admin")
}
// AdminListUsers godoc
// @Summary List all users
// @Description Returns the list of all users
// @Tags Admin
// @Security CookieAuth
// @ID adminListUsers
// @Produce json
// @Success 200 {array} models.User
// @Failure 500 {object} ErrorResponse "Failed to list users"
// @Router /admin/users [get]
func (h *Handler) AdminListUsers() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r)
if !ok {
return
}
log := getAdminLogger().With(
"handler", "AdminListUsers",
"adminID", ctx.UserID,
"clientIP", r.RemoteAddr,
)
users, err := h.DB.GetAllUsers()
if err != nil {
log.Error("failed to fetch users from database",
"error", err.Error(),
)
respondError(w, "Failed to list users", http.StatusInternalServerError)
return
}
respondJSON(w, users)
}
}
// AdminCreateUser godoc
// @Summary Create a new user
// @Description Create a new user as an admin
// @Tags Admin
// @Security CookieAuth
// @ID adminCreateUser
// @Accept json
// @Produce json
// @Param user body CreateUserRequest true "User details"
// @Success 200 {object} models.User
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 400 {object} ErrorResponse "Email, password, and role are required"
// @Failure 400 {object} ErrorResponse "Password must be at least 8 characters"
// @Failure 409 {object} ErrorResponse "Email already exists"
// @Failure 500 {object} ErrorResponse "Failed to hash password"
// @Failure 500 {object} ErrorResponse "Failed to create user"
// @Failure 500 {object} ErrorResponse "Failed to initialize user workspace"
// @Router /admin/users [post]
func (h *Handler) AdminCreateUser() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r)
if !ok {
return
}
log := getAdminLogger().With(
"handler", "AdminCreateUser",
"adminID", ctx.UserID,
"clientIP", r.RemoteAddr,
)
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Debug("failed to decode request body",
"error", err.Error(),
)
respondError(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validation logging
if req.Email == "" || req.Password == "" || req.Role == "" {
log.Debug("missing required fields",
"hasEmail", req.Email != "",
"hasPassword", req.Password != "",
"hasRole", req.Role != "",
)
respondError(w, "Email, password, and role are required", http.StatusBadRequest)
return
}
// Email existence check
existingUser, err := h.DB.GetUserByEmail(req.Email)
if err == nil && existingUser != nil {
log.Warn("attempted to create user with existing email",
"email", req.Email,
)
respondError(w, "Email already exists", http.StatusConflict)
return
}
if len(req.Password) < 8 {
log.Debug("password too short",
"passwordLength", len(req.Password),
)
respondError(w, "Password must be at least 8 characters", http.StatusBadRequest)
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Error("failed to hash password",
"error", err.Error(),
)
respondError(w, "Failed to hash password", http.StatusInternalServerError)
return
}
user := &models.User{
Email: req.Email,
DisplayName: req.DisplayName,
PasswordHash: string(hashedPassword),
Role: req.Role,
}
insertedUser, err := h.DB.CreateUser(user)
if err != nil {
log.Error("failed to create user in database",
"error", err.Error(),
"email", req.Email,
"role", req.Role,
)
respondError(w, "Failed to create user", http.StatusInternalServerError)
return
}
if err := h.Storage.InitializeUserWorkspace(insertedUser.ID, insertedUser.LastWorkspaceID); err != nil {
log.Error("failed to initialize user workspace",
"error", err.Error(),
"userID", insertedUser.ID,
"workspaceID", insertedUser.LastWorkspaceID,
)
respondError(w, "Failed to initialize user workspace", http.StatusInternalServerError)
return
}
log.Info("user created",
"newUserID", insertedUser.ID,
"email", insertedUser.Email,
"role", insertedUser.Role,
)
respondJSON(w, insertedUser)
}
}
// AdminGetUser godoc
// @Summary Get a specific user
// @Description Get a specific user as an admin
// @Tags Admin
// @Security CookieAuth
// @ID adminGetUser
// @Produce json
// @Param userId path int true "User ID"
// @Success 200 {object} models.User
// @Failure 400 {object} ErrorResponse "Invalid user ID"
// @Failure 404 {object} ErrorResponse "User not found"
// @Router /admin/users/{userId} [get]
func (h *Handler) AdminGetUser() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r)
if !ok {
return
}
log := getAdminLogger().With(
"handler", "AdminGetUser",
"adminID", ctx.UserID,
"clientIP", r.RemoteAddr,
)
userID, err := strconv.Atoi(chi.URLParam(r, "userId"))
if err != nil {
log.Debug("invalid user ID format",
"userIDParam", chi.URLParam(r, "userId"),
"error", err.Error(),
)
respondError(w, "Invalid user ID", http.StatusBadRequest)
return
}
user, err := h.DB.GetUserByID(userID)
if err != nil {
log.Debug("user not found",
"targetUserID", userID,
"error", err.Error(),
)
respondError(w, "User not found", http.StatusNotFound)
return
}
respondJSON(w, user)
}
}
// AdminUpdateUser godoc
// @Summary Update a specific user
// @Description Update a specific user as an admin
// @Tags Admin
// @Security CookieAuth
// @ID adminUpdateUser
// @Accept json
// @Produce json
// @Param userId path int true "User ID"
// @Param user body UpdateUserRequest true "User details"
// @Success 200 {object} models.User
// @Failure 400 {object} ErrorResponse "Invalid user ID"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 404 {object} ErrorResponse "User not found"
// @Failure 500 {object} ErrorResponse "Failed to hash password"
// @Failure 500 {object} ErrorResponse "Failed to update user"
// @Router /admin/users/{userId} [put]
func (h *Handler) AdminUpdateUser() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r)
if !ok {
return
}
log := getAdminLogger().With(
"handler", "AdminUpdateUser",
"adminID", ctx.UserID,
"clientIP", r.RemoteAddr,
)
userID, err := strconv.Atoi(chi.URLParam(r, "userId"))
if err != nil {
log.Debug("invalid user ID format",
"userIDParam", chi.URLParam(r, "userId"),
"error", err.Error(),
)
respondError(w, "Invalid user ID", http.StatusBadRequest)
return
}
user, err := h.DB.GetUserByID(userID)
if err != nil {
log.Debug("user not found",
"targetUserID", userID,
"error", err.Error(),
)
respondError(w, "User not found", http.StatusNotFound)
return
}
var req UpdateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Debug("failed to decode request body",
"error", err.Error(),
)
respondError(w, "Invalid request body", http.StatusBadRequest)
return
}
// Track what's being updated for logging
updates := make(map[string]interface{})
if req.Email != "" {
user.Email = req.Email
updates["email"] = req.Email
}
if req.DisplayName != "" {
user.DisplayName = req.DisplayName
updates["displayName"] = req.DisplayName
}
if req.Role != "" {
user.Role = req.Role
updates["role"] = req.Role
}
if req.Password != "" {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Error("failed to hash password",
"error", err.Error(),
)
respondError(w, "Failed to hash password", http.StatusInternalServerError)
return
}
user.PasswordHash = string(hashedPassword)
updates["passwordUpdated"] = true
}
if err := h.DB.UpdateUser(user); err != nil {
log.Error("failed to update user in database",
"error", err.Error(),
"targetUserID", userID,
)
respondError(w, "Failed to update user", http.StatusInternalServerError)
return
}
log.Debug("user updated",
"targetUserID", userID,
"updates", updates,
)
respondJSON(w, user)
}
}
// AdminDeleteUser godoc
// @Summary Delete a specific user
// @Description Delete a specific user as an admin
// @Tags Admin
// @Security CookieAuth
// @ID adminDeleteUser
// @Param userId path int true "User ID"
// @Success 204 "No Content"
// @Failure 400 {object} ErrorResponse "Invalid user ID"
// @Failure 400 {object} ErrorResponse "Cannot delete your own account"
// @Failure 403 {object} ErrorResponse "Cannot delete other admin users"
// @Failure 404 {object} ErrorResponse "User not found"
// @Failure 500 {object} ErrorResponse "Failed to delete user"
// @Router /admin/users/{userId} [delete]
func (h *Handler) AdminDeleteUser() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r)
if !ok {
return
}
log := getAdminLogger().With(
"handler", "AdminDeleteUser",
"adminID", ctx.UserID,
"clientIP", r.RemoteAddr,
)
userID, err := strconv.Atoi(chi.URLParam(r, "userId"))
if err != nil {
log.Debug("invalid user ID format",
"userIDParam", chi.URLParam(r, "userId"),
"error", err.Error(),
)
respondError(w, "Invalid user ID", http.StatusBadRequest)
return
}
if userID == ctx.UserID {
log.Warn("admin attempted to delete own account")
respondError(w, "Cannot delete your own account", http.StatusBadRequest)
return
}
user, err := h.DB.GetUserByID(userID)
if err != nil {
log.Debug("user not found",
"targetUserID", userID,
"error", err.Error(),
)
respondError(w, "User not found", http.StatusNotFound)
return
}
if user.Role == models.RoleAdmin && ctx.UserID != userID {
log.Warn("attempted to delete another admin user",
"targetUserID", userID,
"targetUserEmail", user.Email,
)
respondError(w, "Cannot delete other admin users", http.StatusForbidden)
return
}
if err := h.DB.DeleteUser(userID); err != nil {
log.Error("failed to delete user from database",
"error", err.Error(),
"targetUserID", userID,
)
respondError(w, "Failed to delete user", http.StatusInternalServerError)
return
}
log.Info("user deleted",
"targetUserID", userID,
"targetUserEmail", user.Email,
"targetUserRole", user.Role,
)
w.WriteHeader(http.StatusNoContent)
}
}
// AdminListWorkspaces godoc
// @Summary List all workspaces
// @Description List all workspaces and their stats as an admin
// @Tags Admin
// @Security CookieAuth
// @ID adminListWorkspaces
// @Produce json
// @Success 200 {array} WorkspaceStats
// @Failure 500 {object} ErrorResponse "Failed to list workspaces"
// @Failure 500 {object} ErrorResponse "Failed to get user"
// @Failure 500 {object} ErrorResponse "Failed to get file stats"
// @Router /admin/workspaces [get]
func (h *Handler) AdminListWorkspaces() http.HandlerFunc { func (h *Handler) AdminListWorkspaces() http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r)
if !ok {
return
}
log := getAdminLogger().With(
"handler", "AdminListWorkspaces",
"adminID", ctx.UserID,
"clientIP", r.RemoteAddr,
)
workspaces, err := h.DB.GetAllWorkspaces() workspaces, err := h.DB.GetAllWorkspaces()
if err != nil { if err != nil {
http.Error(w, "Failed to list workspaces", http.StatusInternalServerError) log.Error("failed to fetch workspaces from database",
"error", err.Error(),
)
respondError(w, "Failed to list workspaces", http.StatusInternalServerError)
return return
} }
workspacesStats := make([]*WorkspaceStats, 0, len(workspaces)) workspacesStats := make([]*WorkspaceStats, 0, len(workspaces))
for _, ws := range workspaces { for _, ws := range workspaces {
workspaceData := &WorkspaceStats{} workspaceData := &WorkspaceStats{}
user, err := h.DB.GetUserByID(ws.UserID) user, err := h.DB.GetUserByID(ws.UserID)
if err != nil { if err != nil {
http.Error(w, "Failed to get user", http.StatusInternalServerError) log.Error("failed to fetch user for workspace",
"error", err.Error(),
"workspaceID", ws.ID,
"userID", ws.UserID,
)
respondError(w, "Failed to get user", http.StatusInternalServerError)
return return
} }
@@ -253,12 +488,16 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc {
fileStats, err := h.Storage.GetFileStats(ws.UserID, ws.ID) fileStats, err := h.Storage.GetFileStats(ws.UserID, ws.ID)
if err != nil { if err != nil {
http.Error(w, "Failed to get file stats", http.StatusInternalServerError) log.Error("failed to fetch file stats for workspace",
"error", err.Error(),
"workspaceID", ws.ID,
"userID", ws.UserID,
)
respondError(w, "Failed to get file stats", http.StatusInternalServerError)
return return
} }
workspaceData.FileCountStats = fileStats workspaceData.FileCountStats = fileStats
workspacesStats = append(workspacesStats, workspaceData) workspacesStats = append(workspacesStats, workspaceData)
} }
@@ -266,24 +505,44 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc {
} }
} }
// SystemStats holds system-wide statistics // AdminGetSystemStats godoc
type SystemStats struct { // @Summary Get system statistics
*db.UserStats // @Description Get system-wide statistics as an admin
*storage.FileCountStats // @Tags Admin
} // @Security CookieAuth
// @ID adminGetSystemStats
// AdminGetSystemStats returns system-wide statistics for admins // @Produce json
// @Success 200 {object} SystemStats
// @Failure 500 {object} ErrorResponse "Failed to get user stats"
// @Failure 500 {object} ErrorResponse "Failed to get file stats"
// @Router /admin/stats [get]
func (h *Handler) AdminGetSystemStats() http.HandlerFunc { func (h *Handler) AdminGetSystemStats() http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r)
if !ok {
return
}
log := getAdminLogger().With(
"handler", "AdminGetSystemStats",
"adminID", ctx.UserID,
"clientIP", r.RemoteAddr,
)
userStats, err := h.DB.GetSystemStats() userStats, err := h.DB.GetSystemStats()
if err != nil { if err != nil {
http.Error(w, "Failed to get user stats", http.StatusInternalServerError) log.Error("failed to fetch user statistics",
"error", err.Error(),
)
respondError(w, "Failed to get user stats", http.StatusInternalServerError)
return return
} }
fileStats, err := h.Storage.GetTotalFileStats() fileStats, err := h.Storage.GetTotalFileStats()
if err != nil { if err != nil {
http.Error(w, "Failed to get file stats", http.StatusInternalServerError) log.Error("failed to fetch file statistics",
"error", err.Error(),
)
respondError(w, "Failed to get file stats", http.StatusInternalServerError)
return return
} }

View File

@@ -8,8 +8,8 @@ import (
"net/http" "net/http"
"testing" "testing"
"novamd/internal/handlers" "lemma/internal/handlers"
"novamd/internal/models" "lemma/internal/models"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -34,8 +34,8 @@ func TestAdminHandlers_Integration(t *testing.T) {
t.Run("user management", func(t *testing.T) { t.Run("user management", func(t *testing.T) {
t.Run("list users", func(t *testing.T) { t.Run("list users", func(t *testing.T) {
// Test with admin token // Test with admin session
rr := h.makeRequest(t, http.MethodGet, "/api/v1/admin/users", nil, h.AdminToken, nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/admin/users", nil, h.AdminTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var users []*models.User var users []*models.User
@@ -44,15 +44,15 @@ func TestAdminHandlers_Integration(t *testing.T) {
// Should have at least our admin and regular test users // Should have at least our admin and regular test users
assert.GreaterOrEqual(t, len(users), 2) assert.GreaterOrEqual(t, len(users), 2)
assert.True(t, containsUser(users, h.AdminUser), "Admin user not found in users list") assert.True(t, containsUser(users, h.AdminTestUser.userModel), "Admin user not found in users list")
assert.True(t, containsUser(users, h.RegularUser), "Regular user not found in users list") assert.True(t, containsUser(users, h.RegularTestUser.userModel), "Regular user not found in users list")
// Test with non-admin token // Test with non-admin session
rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/users", nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/users", nil, h.RegularTestUser)
assert.Equal(t, http.StatusForbidden, rr.Code) assert.Equal(t, http.StatusForbidden, rr.Code)
// Test without token // Test without session
rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/users", nil, "", nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/users", nil, nil)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
}) })
@@ -64,8 +64,8 @@ func TestAdminHandlers_Integration(t *testing.T) {
Role: models.RoleEditor, Role: models.RoleEditor,
} }
// Test with admin token // Test with admin session
rr := h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.AdminToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.AdminTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var createdUser models.User var createdUser models.User
@@ -77,7 +77,7 @@ func TestAdminHandlers_Integration(t *testing.T) {
assert.NotZero(t, createdUser.LastWorkspaceID) assert.NotZero(t, createdUser.LastWorkspaceID)
// Test duplicate email // Test duplicate email
rr = h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.AdminTestUser)
assert.Equal(t, http.StatusConflict, rr.Code) assert.Equal(t, http.StatusConflict, rr.Code)
// Test invalid request (missing required fields) // Test invalid request (missing required fields)
@@ -85,44 +85,44 @@ func TestAdminHandlers_Integration(t *testing.T) {
Email: "invalid@test.com", Email: "invalid@test.com",
// Missing password and role // Missing password and role
} }
rr = h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", invalidReq, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", invalidReq, h.AdminTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
// Test with non-admin token // Test with non-admin session
rr = h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.RegularTestUser)
assert.Equal(t, http.StatusForbidden, rr.Code) assert.Equal(t, http.StatusForbidden, rr.Code)
}) })
t.Run("get user", func(t *testing.T) { t.Run("get user", func(t *testing.T) {
path := fmt.Sprintf("/api/v1/admin/users/%d", h.RegularUser.ID) path := fmt.Sprintf("/api/v1/admin/users/%d", h.RegularTestUser.session.UserID)
// Test with admin token // Test with admin session
rr := h.makeRequest(t, http.MethodGet, path, nil, h.AdminToken, nil) rr := h.makeRequest(t, http.MethodGet, path, nil, h.AdminTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var user models.User var user models.User
err := json.NewDecoder(rr.Body).Decode(&user) err := json.NewDecoder(rr.Body).Decode(&user)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, h.RegularUser.ID, user.ID) assert.Equal(t, h.RegularTestUser.session.UserID, user.ID)
// Test non-existent user // Test non-existent user
rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/users/999999", nil, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/users/999999", nil, h.AdminTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
// Test with non-admin token // Test with non-admin session
rr = h.makeRequest(t, http.MethodGet, path, nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, path, nil, h.RegularTestUser)
assert.Equal(t, http.StatusForbidden, rr.Code) assert.Equal(t, http.StatusForbidden, rr.Code)
}) })
t.Run("update user", func(t *testing.T) { t.Run("update user", func(t *testing.T) {
path := fmt.Sprintf("/api/v1/admin/users/%d", h.RegularUser.ID) path := fmt.Sprintf("/api/v1/admin/users/%d", h.RegularTestUser.session.UserID)
updateReq := handlers.UpdateUserRequest{ updateReq := handlers.UpdateUserRequest{
DisplayName: "Updated Name", DisplayName: "Updated Name",
Role: models.RoleViewer, Role: models.RoleViewer,
} }
// Test with admin token // Test with admin session
rr := h.makeRequest(t, http.MethodPut, path, updateReq, h.AdminToken, nil) rr := h.makeRequest(t, http.MethodPut, path, updateReq, h.AdminTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var updatedUser models.User var updatedUser models.User
@@ -131,8 +131,8 @@ func TestAdminHandlers_Integration(t *testing.T) {
assert.Equal(t, updateReq.DisplayName, updatedUser.DisplayName) assert.Equal(t, updateReq.DisplayName, updatedUser.DisplayName)
assert.Equal(t, updateReq.Role, updatedUser.Role) assert.Equal(t, updateReq.Role, updatedUser.Role)
// Test with non-admin token // Test with non-admin session
rr = h.makeRequest(t, http.MethodPut, path, updateReq, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodPut, path, updateReq, h.RegularTestUser)
assert.Equal(t, http.StatusForbidden, rr.Code) assert.Equal(t, http.StatusForbidden, rr.Code)
}) })
@@ -145,7 +145,7 @@ func TestAdminHandlers_Integration(t *testing.T) {
Role: models.RoleEditor, Role: models.RoleEditor,
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.AdminToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.AdminTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var createdUser models.User var createdUser models.User
@@ -155,20 +155,20 @@ func TestAdminHandlers_Integration(t *testing.T) {
path := fmt.Sprintf("/api/v1/admin/users/%d", createdUser.ID) path := fmt.Sprintf("/api/v1/admin/users/%d", createdUser.ID)
// Test deleting own account (should fail) // Test deleting own account (should fail)
adminPath := fmt.Sprintf("/api/v1/admin/users/%d", h.AdminUser.ID) adminPath := fmt.Sprintf("/api/v1/admin/users/%d", h.AdminTestUser.session.UserID)
rr = h.makeRequest(t, http.MethodDelete, adminPath, nil, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodDelete, adminPath, nil, h.AdminTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
// Test with admin token // Test with admin session
rr = h.makeRequest(t, http.MethodDelete, path, nil, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodDelete, path, nil, h.AdminTestUser)
assert.Equal(t, http.StatusNoContent, rr.Code) assert.Equal(t, http.StatusNoContent, rr.Code)
// Verify user is deleted // Verify user is deleted
rr = h.makeRequest(t, http.MethodGet, path, nil, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodGet, path, nil, h.AdminTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
// Test with non-admin token // Test with non-admin session
rr = h.makeRequest(t, http.MethodDelete, path, nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodDelete, path, nil, h.RegularTestUser)
assert.Equal(t, http.StatusForbidden, rr.Code) assert.Equal(t, http.StatusForbidden, rr.Code)
}) })
}) })
@@ -177,15 +177,15 @@ func TestAdminHandlers_Integration(t *testing.T) {
t.Run("list workspaces", func(t *testing.T) { t.Run("list workspaces", func(t *testing.T) {
// Create a test workspace first // Create a test workspace first
workspace := &models.Workspace{ workspace := &models.Workspace{
UserID: h.RegularUser.ID, UserID: h.RegularTestUser.session.UserID,
Name: "Test Workspace", Name: "Test Workspace",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
// Test with admin token // Test with admin session
rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/workspaces", nil, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/workspaces", nil, h.AdminTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var workspaces []*handlers.WorkspaceStats var workspaces []*handlers.WorkspaceStats
@@ -206,8 +206,8 @@ func TestAdminHandlers_Integration(t *testing.T) {
assert.GreaterOrEqual(t, ws.TotalSize, int64(0)) assert.GreaterOrEqual(t, ws.TotalSize, int64(0))
} }
// Test with non-admin token // Test with non-admin session
rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/workspaces", nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/workspaces", nil, h.RegularTestUser)
assert.Equal(t, http.StatusForbidden, rr.Code) assert.Equal(t, http.StatusForbidden, rr.Code)
}) })
}) })
@@ -215,14 +215,14 @@ func TestAdminHandlers_Integration(t *testing.T) {
t.Run("system stats", func(t *testing.T) { t.Run("system stats", func(t *testing.T) {
// Create some test data // Create some test data
workspace := &models.Workspace{ workspace := &models.Workspace{
UserID: h.RegularUser.ID, UserID: h.RegularTestUser.session.UserID,
Name: "Stats Test Workspace", Name: "Stats Test Workspace",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
// Test with admin token // Test with admin session
rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/stats", nil, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/stats", nil, h.AdminTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var stats handlers.SystemStats var stats handlers.SystemStats
@@ -236,8 +236,8 @@ func TestAdminHandlers_Integration(t *testing.T) {
assert.GreaterOrEqual(t, stats.TotalFiles, 0) assert.GreaterOrEqual(t, stats.TotalFiles, 0)
assert.GreaterOrEqual(t, stats.TotalSize, int64(0)) assert.GreaterOrEqual(t, stats.TotalSize, int64(0))
// Test with non-admin token // Test with non-admin session
rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/stats", nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/stats", nil, h.RegularTestUser)
assert.Equal(t, http.StatusForbidden, rr.Code) assert.Equal(t, http.StatusForbidden, rr.Code)
}) })
} }

View File

@@ -1,11 +1,15 @@
package handlers package handlers
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"lemma/internal/auth"
"lemma/internal/context"
"lemma/internal/logging"
"lemma/internal/models"
"net/http" "net/http"
"novamd/internal/auth" "time"
"novamd/internal/context"
"novamd/internal/models"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -18,130 +22,252 @@ type LoginRequest struct {
// LoginResponse represents a user login response // LoginResponse represents a user login response
type LoginResponse struct { type LoginResponse struct {
AccessToken string `json:"accessToken"` User *models.User `json:"user"`
RefreshToken string `json:"refreshToken"` SessionID string `json:"sessionId,omitempty"`
User *models.User `json:"user"` ExpiresAt time.Time `json:"expiresAt,omitempty"`
Session *models.Session `json:"session"`
} }
// RefreshRequest represents a refresh token request func getAuthLogger() logging.Logger {
type RefreshRequest struct { return getHandlersLogger().WithGroup("auth")
RefreshToken string `json:"refreshToken"`
} }
// RefreshResponse represents a refresh token response // Login godoc
type RefreshResponse struct { // @Summary Login
AccessToken string `json:"accessToken"` // @Description Logs in a user and returns a session with access and refresh tokens
} // @Tags auth
// @Accept json
// Login handles user authentication and returns JWT tokens // @Produce json
func (h *Handler) Login(authService *auth.SessionService) http.HandlerFunc { // @Param body body LoginRequest true "Login request"
// @Success 200 {object} LoginResponse
// @Header 200 {string} X-CSRF-Token "CSRF token for future requests"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 400 {object} ErrorResponse "Email and password are required"
// @Failure 401 {object} ErrorResponse "Invalid credentials"
// @Failure 500 {object} ErrorResponse "Failed to create session"
// @Failure 500 {object} ErrorResponse "Failed to generate CSRF token"
// @Router /auth/login [post]
func (h *Handler) Login(authManager auth.SessionManager, cookieService auth.CookieManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
log := getAuthLogger().With(
"handler", "Login",
"clientIP", r.RemoteAddr,
)
var req LoginRequest var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest) log.Debug("failed to decode request body",
"error", err.Error(),
)
respondError(w, "Invalid request body", http.StatusBadRequest)
return return
} }
// Validate request
if req.Email == "" || req.Password == "" { if req.Email == "" || req.Password == "" {
http.Error(w, "Email and password are required", http.StatusBadRequest) log.Debug("missing required fields",
"hasEmail", req.Email != "",
"hasPassword", req.Password != "",
)
respondError(w, "Email and password are required", http.StatusBadRequest)
return return
} }
// Get user from database
user, err := h.DB.GetUserByEmail(req.Email) user, err := h.DB.GetUserByEmail(req.Email)
if err != nil { if err != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized) log.Debug("user not found",
"email", req.Email,
"error", err.Error(),
)
respondError(w, "Invalid credentials", http.StatusUnauthorized)
return return
} }
// Verify password
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password))
if err != nil { if err != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized) log.Warn("invalid password attempt",
"userID", user.ID,
"email", user.Email,
)
respondError(w, "Invalid credentials", http.StatusUnauthorized)
return return
} }
// Create session and generate tokens session, accessToken, err := authManager.CreateSession(user.ID, string(user.Role))
session, accessToken, err := authService.CreateSession(user.ID, string(user.Role))
if err != nil { if err != nil {
http.Error(w, "Failed to create session", http.StatusInternalServerError) log.Error("failed to create session",
"error", err.Error(),
"userID", user.ID,
)
respondError(w, "Failed to create session", http.StatusInternalServerError)
return return
} }
// Prepare response csrfToken := make([]byte, 32)
if _, err := rand.Read(csrfToken); err != nil {
log.Error("failed to generate CSRF token",
"error", err.Error(),
"userID", user.ID,
)
respondError(w, "Failed to generate CSRF token", http.StatusInternalServerError)
return
}
csrfTokenString := hex.EncodeToString(csrfToken)
http.SetCookie(w, cookieService.GenerateAccessTokenCookie(accessToken))
http.SetCookie(w, cookieService.GenerateRefreshTokenCookie(session.RefreshToken))
http.SetCookie(w, cookieService.GenerateCSRFCookie(csrfTokenString))
w.Header().Set("X-CSRF-Token", csrfTokenString)
response := LoginResponse{ response := LoginResponse{
AccessToken: accessToken, User: user,
RefreshToken: session.RefreshToken, SessionID: session.ID,
User: user, ExpiresAt: session.ExpiresAt,
Session: session,
} }
log.Debug("user logged in",
"userID", user.ID,
"email", user.Email,
"role", user.Role,
"sessionID", session.ID,
)
respondJSON(w, response) respondJSON(w, response)
} }
} }
// Logout invalidates the user's session // Logout godoc
func (h *Handler) Logout(authService *auth.SessionService) http.HandlerFunc { // @Summary Logout
// @Description Log out invalidates the user's session
// @Tags auth
// @ID logout
// @Success 204 "No Content"
// @Failure 400 {object} ErrorResponse "Session ID required"
// @Failure 500 {object} ErrorResponse "Failed to logout"
// @Router /auth/logout [post]
func (h *Handler) Logout(authManager auth.SessionManager, cookieService auth.CookieManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
sessionID := r.Header.Get("X-Session-ID") ctx, ok := context.GetRequestContext(w, r)
if sessionID == "" { if !ok {
http.Error(w, "Session ID required", http.StatusBadRequest)
return return
} }
log := getAuthLogger().With(
"handler", "Logout",
"userID", ctx.UserID,
"clientIP", r.RemoteAddr,
)
err := authService.InvalidateSession(sessionID) sessionCookie, err := r.Cookie("access_token")
if err != nil { if err != nil {
http.Error(w, "Failed to logout", http.StatusInternalServerError) log.Debug("missing access token cookie",
"error", err.Error(),
)
respondError(w, "Access token required", http.StatusBadRequest)
return return
} }
if err := authManager.InvalidateSession(sessionCookie.Value); err != nil {
log.Error("failed to invalidate session",
"error", err.Error(),
"sessionID", sessionCookie.Value,
)
respondError(w, "Failed to invalidate session", http.StatusInternalServerError)
return
}
http.SetCookie(w, cookieService.InvalidateCookie("access_token"))
http.SetCookie(w, cookieService.InvalidateCookie("refresh_token"))
http.SetCookie(w, cookieService.InvalidateCookie("csrf_token"))
log.Info("user logged out successfully",
"sessionID", sessionCookie.Value,
)
w.WriteHeader(http.StatusNoContent)
}
}
// RefreshToken godoc
// @Summary Refresh token
// @Description Refreshes the access token using the refresh token
// @Tags auth
// @ID refreshToken
// @Accept json
// @Produce json
// @Success 200
// @Header 200 {string} X-CSRF-Token "New CSRF token"
// @Failure 400 {object} ErrorResponse "Refresh token required"
// @Failure 401 {object} ErrorResponse "Invalid refresh token"
// @Failure 500 {object} ErrorResponse "Failed to generate CSRF token"
// @Router /auth/refresh [post]
func (h *Handler) RefreshToken(authManager auth.SessionManager, cookieService auth.CookieManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := getAuthLogger().With(
"handler", "RefreshToken",
"clientIP", r.RemoteAddr,
)
refreshCookie, err := r.Cookie("refresh_token")
if err != nil {
log.Debug("missing refresh token cookie",
"error", err.Error(),
)
respondError(w, "Refresh token required", http.StatusBadRequest)
return
}
accessToken, err := authManager.RefreshSession(refreshCookie.Value)
if err != nil {
log.Error("failed to refresh session",
"error", err.Error(),
)
respondError(w, "Invalid refresh token", http.StatusUnauthorized)
return
}
csrfToken := make([]byte, 32)
if _, err := rand.Read(csrfToken); err != nil {
log.Error("failed to generate CSRF token",
"error", err.Error(),
)
respondError(w, "Failed to generate CSRF token", http.StatusInternalServerError)
return
}
csrfTokenString := hex.EncodeToString(csrfToken)
http.SetCookie(w, cookieService.GenerateAccessTokenCookie(accessToken))
http.SetCookie(w, cookieService.GenerateCSRFCookie(csrfTokenString))
w.Header().Set("X-CSRF-Token", csrfTokenString)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
} }
// RefreshToken generates a new access token using a refresh token // GetCurrentUser godoc
func (h *Handler) RefreshToken(authService *auth.SessionService) http.HandlerFunc { // @Summary Get current user
return func(w http.ResponseWriter, r *http.Request) { // @Description Returns the current authenticated user
var req RefreshRequest // @Tags auth
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { // @ID getCurrentUser
http.Error(w, "Invalid request body", http.StatusBadRequest) // @Security CookieAuth
return // @Produce json
} // @Success 200 {object} models.User
// @Failure 404 {object} ErrorResponse "User not found"
if req.RefreshToken == "" { // @Router /auth/me [get]
http.Error(w, "Refresh token required", http.StatusBadRequest)
return
}
// Generate new access token
accessToken, err := authService.RefreshSession(req.RefreshToken)
if err != nil {
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
return
}
response := RefreshResponse{
AccessToken: accessToken,
}
respondJSON(w, response)
}
}
// GetCurrentUser returns the currently authenticated user
func (h *Handler) GetCurrentUser() http.HandlerFunc { func (h *Handler) GetCurrentUser() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getAuthLogger().With(
"handler", "GetCurrentUser",
"userID", ctx.UserID,
"clientIP", r.RemoteAddr,
)
// Get user from database
user, err := h.DB.GetUserByID(ctx.UserID) user, err := h.DB.GetUserByID(ctx.UserID)
if err != nil { if err != nil {
http.Error(w, "User not found", http.StatusNotFound) log.Error("failed to fetch user",
"error", err.Error(),
)
respondError(w, "User not found", http.StatusNotFound)
return return
} }

View File

@@ -4,11 +4,15 @@ package handlers_test
import ( import (
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"net/http/httptest"
"strings"
"testing" "testing"
"time"
"novamd/internal/handlers" "lemma/internal/handlers"
"novamd/internal/models" "lemma/internal/models"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -25,40 +29,58 @@ func TestAuthHandlers_Integration(t *testing.T) {
Password: "admin123", Password: "admin123",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, "", nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, nil)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
// Verify all required cookies are present with correct attributes
cookies := rr.Result().Cookies()
var foundAccessToken, foundRefreshToken, foundCSRF bool
for _, cookie := range cookies {
switch cookie.Name {
case "access_token":
foundAccessToken = true
assert.True(t, cookie.HttpOnly, "access_token cookie must be HttpOnly")
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
assert.Equal(t, 900, cookie.MaxAge) // 15 minutes
case "refresh_token":
foundRefreshToken = true
assert.True(t, cookie.HttpOnly, "refresh_token cookie must be HttpOnly")
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
assert.Equal(t, 604800, cookie.MaxAge) // 7 days
case "csrf_token":
foundCSRF = true
assert.False(t, cookie.HttpOnly, "csrf_token cookie must not be HttpOnly")
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
assert.Equal(t, 900, cookie.MaxAge) // 15 minutes
}
}
assert.True(t, foundAccessToken, "access_token cookie not found")
assert.True(t, foundRefreshToken, "refresh_token cookie not found")
assert.True(t, foundCSRF, "csrf_token cookie not found")
// Verify CSRF token is in both cookie and header, and they match
var csrfCookie *http.Cookie
for _, cookie := range rr.Result().Cookies() {
if cookie.Name == "csrf_token" {
csrfCookie = cookie
break
}
}
require.NotNil(t, csrfCookie, "csrf_token cookie not found")
csrfHeader := rr.Header().Get("X-CSRF-Token")
assert.Equal(t, csrfCookie.Value, csrfHeader)
// Verify response body
var resp handlers.LoginResponse var resp handlers.LoginResponse
err := json.NewDecoder(rr.Body).Decode(&resp) err := json.NewDecoder(rr.Body).Decode(&resp)
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, resp.SessionID)
assert.NotEmpty(t, resp.AccessToken) assert.False(t, resp.ExpiresAt.IsZero())
assert.NotEmpty(t, resp.RefreshToken)
assert.NotNil(t, resp.User) assert.NotNil(t, resp.User)
assert.Equal(t, loginReq.Email, resp.User.Email) assert.Equal(t, loginReq.Email, resp.User.Email)
assert.Equal(t, models.RoleAdmin, resp.User.Role) assert.Equal(t, models.RoleAdmin, resp.User.Role)
}) })
t.Run("successful login - regular user", func(t *testing.T) {
loginReq := handlers.LoginRequest{
Email: "user@test.com",
Password: "user123",
}
rr := h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, "", nil)
require.Equal(t, http.StatusOK, rr.Code)
var resp handlers.LoginResponse
err := json.NewDecoder(rr.Body).Decode(&resp)
require.NoError(t, err)
assert.NotEmpty(t, resp.AccessToken)
assert.NotEmpty(t, resp.RefreshToken)
assert.NotNil(t, resp.User)
assert.Equal(t, loginReq.Email, resp.User.Email)
assert.Equal(t, models.RoleEditor, resp.User.Role)
})
t.Run("login failures", func(t *testing.T) { t.Run("login failures", func(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -97,12 +119,26 @@ func TestAuthHandlers_Integration(t *testing.T) {
}, },
wantCode: http.StatusBadRequest, wantCode: http.StatusBadRequest,
}, },
{
name: "malformed JSON",
request: handlers.LoginRequest{}, // Will be overridden with bad JSON
wantCode: http.StatusBadRequest,
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
rr := h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", tt.request, "", nil) var rr *httptest.ResponseRecorder
if tt.name == "malformed JSON" {
// Need lower level helper to send malformed JSON
req := h.newRequest(t, http.MethodPost, "/api/v1/auth/login", nil)
req.Body = io.NopCloser(strings.NewReader("{bad json"))
rr = h.executeRequest(req)
} else {
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", tt.request, nil)
}
assert.Equal(t, tt.wantCode, rr.Code) assert.Equal(t, tt.wantCode, rr.Code)
assert.Empty(t, rr.Result().Cookies(), "failed login should not set cookies")
}) })
} }
}) })
@@ -110,58 +146,81 @@ func TestAuthHandlers_Integration(t *testing.T) {
t.Run("refresh token", func(t *testing.T) { t.Run("refresh token", func(t *testing.T) {
t.Run("successful token refresh", func(t *testing.T) { t.Run("successful token refresh", func(t *testing.T) {
// First login to get refresh token // Need lower level helpers for precise cookie control
loginReq := handlers.LoginRequest{ req := h.newRequest(t, http.MethodPost, "/api/v1/auth/refresh", nil)
Email: "user@test.com", h.addAuthCookies(t, req, h.RegularTestUser) // Adds both tokens
Password: "user123", h.addCSRFCookie(t, req)
} rr := h.executeRequest(req)
rr := h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, "", nil)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var loginResp handlers.LoginResponse // Verify new cookies
err := json.NewDecoder(rr.Body).Decode(&loginResp) cookies := rr.Result().Cookies()
require.NoError(t, err) var foundAccessToken, foundCSRF bool
for _, cookie := range cookies {
// Now try to refresh the token switch cookie.Name {
refreshReq := handlers.RefreshRequest{ case "access_token":
RefreshToken: loginResp.RefreshToken, foundAccessToken = true
assert.Equal(t, 900, cookie.MaxAge)
case "csrf_token":
foundCSRF = true
assert.Equal(t, 900, cookie.MaxAge)
case "refresh_token":
t.Error("refresh token should not be renewed")
}
} }
assert.True(t, foundAccessToken, "new access_token cookie not found")
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/refresh", refreshReq, "", nil) assert.True(t, foundCSRF, "new csrf_token cookie not found")
require.Equal(t, http.StatusOK, rr.Code)
var refreshResp handlers.RefreshResponse
err = json.NewDecoder(rr.Body).Decode(&refreshResp)
require.NoError(t, err)
assert.NotEmpty(t, refreshResp.AccessToken)
}) })
t.Run("refresh failures", func(t *testing.T) { t.Run("refresh token edge cases", func(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
request handlers.RefreshRequest setup func(*http.Request)
wantCode int wantCode int
}{ }{
{ {
name: "invalid refresh token", name: "missing refresh token cookie",
request: handlers.RefreshRequest{ setup: func(req *http.Request) {
RefreshToken: "invalid-token", // Only add access token
req.AddCookie(h.CookieManager.GenerateAccessTokenCookie(h.RegularTestUser.accessToken))
},
wantCode: http.StatusBadRequest,
},
{
name: "expired refresh token",
setup: func(req *http.Request) {
expiredSession := &models.Session{
ID: "expired",
UserID: h.RegularTestUser.session.UserID,
RefreshToken: "expired-token",
ExpiresAt: time.Now().Add(-1 * time.Hour),
}
expiredSessionUser := &testUser{
userModel: h.RegularTestUser.userModel,
accessToken: h.RegularTestUser.accessToken,
session: expiredSession,
}
h.addAuthCookies(t, req, expiredSessionUser)
}, },
wantCode: http.StatusUnauthorized, wantCode: http.StatusUnauthorized,
}, },
{ {
name: "empty refresh token", name: "invalid refresh token format",
request: handlers.RefreshRequest{ setup: func(req *http.Request) {
RefreshToken: "", req.AddCookie(&http.Cookie{
Name: "refresh_token",
Value: "invalid-format",
})
}, },
wantCode: http.StatusBadRequest, wantCode: http.StatusUnauthorized,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
rr := h.makeRequest(t, http.MethodPost, "/api/v1/auth/refresh", tt.request, "", nil) req := h.newRequest(t, http.MethodPost, "/api/v1/auth/refresh", nil)
tt.setup(req)
rr := h.executeRequest(req)
assert.Equal(t, tt.wantCode, rr.Code) assert.Equal(t, tt.wantCode, rr.Code)
}) })
} }
@@ -170,63 +229,156 @@ func TestAuthHandlers_Integration(t *testing.T) {
t.Run("logout", func(t *testing.T) { t.Run("logout", func(t *testing.T) {
t.Run("successful logout", func(t *testing.T) { t.Run("successful logout", func(t *testing.T) {
// First login to get session // Need CSRF token for POST request
loginReq := handlers.LoginRequest{ req := h.newRequest(t, http.MethodPost, "/api/v1/auth/logout", nil)
Email: "user@test.com", h.addAuthCookies(t, req, h.RegularTestUser)
Password: "user123", csrfToken := h.addCSRFCookie(t, req)
req.Header.Set("X-CSRF-Token", csrfToken)
rr := h.executeRequest(req)
require.Equal(t, http.StatusNoContent, rr.Code)
// Verify cookies are properly invalidated
for _, cookie := range rr.Result().Cookies() {
assert.True(t, cookie.MaxAge < 0, "cookie should be invalidated")
assert.True(t, cookie.Expires.Before(time.Now()), "cookie should be expired")
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, "", nil) // Verify session is actually invalidated
require.Equal(t, http.StatusOK, rr.Code) rr = h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, h.RegularTestUser)
var loginResp handlers.LoginResponse
err := json.NewDecoder(rr.Body).Decode(&loginResp)
require.NoError(t, err)
// Now logout using session ID from login response
headers := map[string]string{
"X-Session-ID": loginResp.Session.ID,
}
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/logout", nil, loginResp.AccessToken, headers)
require.Equal(t, http.StatusOK, rr.Code)
// Try to use the refresh token - should fail
refreshReq := handlers.RefreshRequest{
RefreshToken: loginResp.RefreshToken,
}
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/refresh", refreshReq, "", nil)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
}) })
t.Run("logout without session ID", func(t *testing.T) { t.Run("logout edge cases", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodPost, "/api/v1/auth/logout", nil, h.RegularToken, nil) tests := []struct {
assert.Equal(t, http.StatusBadRequest, rr.Code) name string
setup func(*http.Request, *testUser)
wantCode int
}{
{
name: "missing CSRF token",
setup: func(req *http.Request, tu *testUser) {
h.addAuthCookies(t, req, tu)
h.addCSRFCookie(t, req)
// Deliberately not setting X-CSRF-Token header
},
wantCode: http.StatusForbidden,
},
{
name: "mismatched CSRF token",
setup: func(req *http.Request, tu *testUser) {
h.addAuthCookies(t, req, tu)
h.addCSRFCookie(t, req)
req.Header.Set("X-CSRF-Token", "wrong-token")
},
wantCode: http.StatusForbidden,
},
{
name: "missing auth cookies",
setup: func(req *http.Request, tu *testUser) {
// No setup - testing completely unauthenticated request
},
wantCode: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a unique user for each test case
// Construct a unique email address from test name
uniqueUserEmail := strings.Replace(tt.name, " ", "", -1) + "@test.com"
logoutTestUser := h.createTestUser(t, uniqueUserEmail, "password123", models.RoleEditor)
req := h.newRequest(t, http.MethodPost, "/api/v1/auth/logout", nil)
tt.setup(req, logoutTestUser)
rr := h.executeRequest(req)
assert.Equal(t, tt.wantCode, rr.Code)
})
}
}) })
}) })
t.Run("get current user", func(t *testing.T) { t.Run("get current user", func(t *testing.T) {
getTestUser := h.createTestUser(t, "testgetuser@test.com", "password123", models.RoleEditor)
t.Run("successful get current user", func(t *testing.T) { t.Run("successful get current user", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, getTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var user models.User var user models.User
err := json.NewDecoder(rr.Body).Decode(&user) err := json.NewDecoder(rr.Body).Decode(&user)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, getTestUser.userModel.Email, user.Email)
assert.Equal(t, h.RegularUser.ID, user.ID)
assert.Equal(t, h.RegularUser.Email, user.Email)
assert.Equal(t, h.RegularUser.Role, user.Role)
}) })
t.Run("get current user without token", func(t *testing.T) { t.Run("auth edge cases", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, "", nil) tests := []struct {
assert.Equal(t, http.StatusUnauthorized, rr.Code) name string
}) setup func(*http.Request)
wantCode int
}{
{
name: "missing auth cookie",
setup: func(req *http.Request) {
// No setup - testing unauthenticated request
},
wantCode: http.StatusUnauthorized,
},
{
name: "invalid session ID",
setup: func(req *http.Request) {
invalidSession := &models.Session{
ID: "invalid",
UserID: 999,
RefreshToken: "invalid",
ExpiresAt: time.Now().Add(time.Hour),
}
invalidSessionUser := &testUser{
userModel: h.RegularTestUser.userModel,
accessToken: h.RegularTestUser.accessToken,
session: invalidSession,
}
h.addAuthCookies(t, req, invalidSessionUser)
},
wantCode: http.StatusUnauthorized,
},
{
name: "expired session",
setup: func(req *http.Request) {
expiredSession := &models.Session{
ID: "expired",
UserID: h.RegularTestUser.session.UserID,
RefreshToken: "expired-token",
ExpiresAt: time.Now().Add(-1 * time.Hour),
}
expiredSessionUser := &testUser{
userModel: h.RegularTestUser.userModel,
accessToken: h.RegularTestUser.accessToken,
session: expiredSession,
}
h.addAuthCookies(t, req, expiredSessionUser)
},
wantCode: http.StatusUnauthorized,
},
{
name: "malformed access token",
setup: func(req *http.Request) {
req.AddCookie(&http.Cookie{
Name: "access_token",
Value: "malformed-token",
})
},
wantCode: http.StatusUnauthorized,
},
}
t.Run("get current user with invalid token", func(t *testing.T) { for _, tt := range tests {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, "invalid-token", nil) t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, rr.Code) req := h.newRequest(t, http.MethodGet, "/api/v1/auth/me", nil)
tt.setup(req)
rr := h.executeRequest(req)
assert.Equal(t, tt.wantCode, rr.Code)
})
}
}) })
}) })
} }

View File

@@ -5,24 +5,71 @@ import (
"io" "io"
"net/http" "net/http"
"os" "os"
"time"
"novamd/internal/context" "lemma/internal/context"
"novamd/internal/storage" "lemma/internal/logging"
"lemma/internal/storage"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
// ListFiles returns a list of all files in the workspace // LookupResponse represents a response to a file lookup request
type LookupResponse struct {
Paths []string `json:"paths"`
}
// SaveFileResponse represents a response to a save file request
type SaveFileResponse struct {
FilePath string `json:"filePath"`
Size int64 `json:"size"`
UpdatedAt time.Time `json:"updatedAt"`
}
// LastOpenedFileResponse represents a response to a last opened file request
type LastOpenedFileResponse struct {
LastOpenedFilePath string `json:"lastOpenedFilePath"`
}
// UpdateLastOpenedFileRequest represents a request to update the last opened file
type UpdateLastOpenedFileRequest struct {
FilePath string `json:"filePath"`
}
func getFilesLogger() logging.Logger {
return getHandlersLogger().WithGroup("files")
}
// ListFiles godoc
// @Summary List files
// @Description Lists all files in the user's workspace
// @Tags files
// @ID listFiles
// @Security CookieAuth
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Success 200 {array} storage.FileNode
// @Failure 500 {object} ErrorResponse "Failed to list files"
// @Router /workspaces/{workspace_name}/files [get]
func (h *Handler) ListFiles() http.HandlerFunc { func (h *Handler) ListFiles() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getFilesLogger().With(
"handler", "ListFiles",
"userID", ctx.UserID,
"workspaceID", ctx.Workspace.ID,
"clientIP", r.RemoteAddr,
)
files, err := h.Storage.ListFilesRecursively(ctx.UserID, ctx.Workspace.ID) files, err := h.Storage.ListFilesRecursively(ctx.UserID, ctx.Workspace.ID)
if err != nil { if err != nil {
http.Error(w, "Failed to list files", http.StatusInternalServerError) log.Error("failed to list files in workspace",
"error", err.Error(),
)
respondError(w, "Failed to list files", http.StatusInternalServerError)
return return
} }
@@ -30,166 +77,337 @@ func (h *Handler) ListFiles() http.HandlerFunc {
} }
} }
// LookupFileByName returns the paths of files with the given name // LookupFileByName godoc
// @Summary Lookup file by name
// @Description Returns the paths of files with the given name in the user's workspace
// @Tags files
// @ID lookupFileByName
// @Security CookieAuth
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Param filename query string true "File name"
// @Success 200 {object} LookupResponse
// @Failure 400 {object} ErrorResponse "Filename is required"
// @Failure 404 {object} ErrorResponse "File not found"
// @Router /workspaces/{workspace_name}/files/lookup [get]
func (h *Handler) LookupFileByName() http.HandlerFunc { func (h *Handler) LookupFileByName() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getFilesLogger().With(
"handler", "LookupFileByName",
"userID", ctx.UserID,
"workspaceID", ctx.Workspace.ID,
"clientIP", r.RemoteAddr,
)
filename := r.URL.Query().Get("filename") filename := r.URL.Query().Get("filename")
if filename == "" { if filename == "" {
http.Error(w, "Filename is required", http.StatusBadRequest) log.Debug("missing filename parameter")
respondError(w, "Filename is required", http.StatusBadRequest)
return return
} }
filePaths, err := h.Storage.FindFileByName(ctx.UserID, ctx.Workspace.ID, filename) filePaths, err := h.Storage.FindFileByName(ctx.UserID, ctx.Workspace.ID, filename)
if err != nil { if err != nil {
http.Error(w, "File not found", http.StatusNotFound) if !os.IsNotExist(err) {
log.Error("failed to lookup file",
"filename", filename,
"error", err.Error(),
)
} else {
log.Debug("file not found",
"filename", filename,
)
}
respondError(w, "File not found", http.StatusNotFound)
return return
} }
respondJSON(w, map[string][]string{"paths": filePaths}) respondJSON(w, &LookupResponse{Paths: filePaths})
} }
} }
// GetFileContent returns the content of a file // GetFileContent godoc
// @Summary Get file content
// @Description Returns the content of a file in the user's workspace
// @Tags files
// @ID getFileContent
// @Security CookieAuth
// @Produce plain
// @Param workspace_name path string true "Workspace name"
// @Param file_path path string true "File path"
// @Success 200 {string} string "Raw file content"
// @Failure 400 {object} ErrorResponse "Invalid file path"
// @Failure 404 {object} ErrorResponse "File not found"
// @Failure 500 {object} ErrorResponse "Failed to read file"
// @Failure 500 {object} ErrorResponse "Failed to write response"
// @Router /workspaces/{workspace_name}/files/{file_path} [get]
func (h *Handler) GetFileContent() http.HandlerFunc { func (h *Handler) GetFileContent() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getFilesLogger().With(
"handler", "GetFileContent",
"userID", ctx.UserID,
"workspaceID", ctx.Workspace.ID,
"clientIP", r.RemoteAddr,
)
filePath := chi.URLParam(r, "*") filePath := chi.URLParam(r, "*")
content, err := h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, filePath) content, err := h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, filePath)
if err != nil { if err != nil {
if storage.IsPathValidationError(err) { if storage.IsPathValidationError(err) {
http.Error(w, "Invalid file path", http.StatusBadRequest) log.Error("invalid file path attempted",
"filePath", filePath,
"error", err.Error(),
)
respondError(w, "Invalid file path", http.StatusBadRequest)
return return
} }
if os.IsNotExist(err) { if os.IsNotExist(err) {
http.Error(w, "Failed to read file", http.StatusNotFound) log.Debug("file not found",
"filePath", filePath,
)
respondError(w, "File not found", http.StatusNotFound)
return return
} }
http.Error(w, "Failed to read file", http.StatusInternalServerError) log.Error("failed to read file content",
"filePath", filePath,
"error", err.Error(),
)
respondError(w, "Failed to read file", http.StatusInternalServerError)
return return
} }
w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Type", "text/plain")
_, err = w.Write(content) _, err = w.Write(content)
if err != nil { if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError) log.Error("failed to write response",
"filePath", filePath,
"error", err.Error(),
)
respondError(w, "Failed to write response", http.StatusInternalServerError)
return return
} }
} }
} }
// SaveFile saves the content of a file // SaveFile godoc
// @Summary Save file
// @Description Saves the content of a file in the user's workspace
// @Tags files
// @ID saveFile
// @Security CookieAuth
// @Accept plain
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Param file_path path string true "File path"
// @Success 200 {object} SaveFileResponse
// @Failure 400 {object} ErrorResponse "Failed to read request body"
// @Failure 400 {object} ErrorResponse "Invalid file path"
// @Failure 500 {object} ErrorResponse "Failed to save file"
// @Router /workspaces/{workspace_name}/files/{file_path} [post]
func (h *Handler) SaveFile() http.HandlerFunc { func (h *Handler) SaveFile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getFilesLogger().With(
"handler", "SaveFile",
"userID", ctx.UserID,
"workspaceID", ctx.Workspace.ID,
"clientIP", r.RemoteAddr,
)
filePath := chi.URLParam(r, "*") filePath := chi.URLParam(r, "*")
content, err := io.ReadAll(r.Body) content, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest) log.Error("failed to read request body",
"filePath", filePath,
"error", err.Error(),
)
respondError(w, "Failed to read request body", http.StatusBadRequest)
return return
} }
err = h.Storage.SaveFile(ctx.UserID, ctx.Workspace.ID, filePath, content) err = h.Storage.SaveFile(ctx.UserID, ctx.Workspace.ID, filePath, content)
if err != nil { if err != nil {
if storage.IsPathValidationError(err) { if storage.IsPathValidationError(err) {
http.Error(w, "Invalid file path", http.StatusBadRequest) log.Error("invalid file path attempted",
"filePath", filePath,
"error", err.Error(),
)
respondError(w, "Invalid file path", http.StatusBadRequest)
return return
} }
http.Error(w, "Failed to save file", http.StatusInternalServerError) log.Error("failed to save file",
"filePath", filePath,
"contentSize", len(content),
"error", err.Error(),
)
respondError(w, "Failed to save file", http.StatusInternalServerError)
return return
} }
respondJSON(w, map[string]string{"message": "File saved successfully"}) response := SaveFileResponse{
FilePath: filePath,
Size: int64(len(content)),
UpdatedAt: time.Now().UTC(),
}
respondJSON(w, response)
} }
} }
// DeleteFile deletes a file // DeleteFile godoc
// @Summary Delete file
// @Description Deletes a file in the user's workspace
// @Tags files
// @ID deleteFile
// @Security CookieAuth
// @Param workspace_name path string true "Workspace name"
// @Param file_path path string true "File path"
// @Success 204 "No Content - File deleted successfully"
// @Failure 400 {object} ErrorResponse "Invalid file path"
// @Failure 404 {object} ErrorResponse "File not found"
// @Failure 500 {object} ErrorResponse "Failed to delete file"
// @Router /workspaces/{workspace_name}/files/{file_path} [delete]
func (h *Handler) DeleteFile() http.HandlerFunc { func (h *Handler) DeleteFile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getFilesLogger().With(
"handler", "DeleteFile",
"userID", ctx.UserID,
"workspaceID", ctx.Workspace.ID,
"clientIP", r.RemoteAddr,
)
filePath := chi.URLParam(r, "*") filePath := chi.URLParam(r, "*")
err := h.Storage.DeleteFile(ctx.UserID, ctx.Workspace.ID, filePath) err := h.Storage.DeleteFile(ctx.UserID, ctx.Workspace.ID, filePath)
if err != nil { if err != nil {
if storage.IsPathValidationError(err) { if storage.IsPathValidationError(err) {
http.Error(w, "Invalid file path", http.StatusBadRequest) log.Error("invalid file path attempted",
"filePath", filePath,
"error", err.Error(),
)
respondError(w, "Invalid file path", http.StatusBadRequest)
return return
} }
if os.IsNotExist(err) { if os.IsNotExist(err) {
http.Error(w, "File not found", http.StatusNotFound) log.Debug("file not found",
"filePath", filePath,
)
respondError(w, "File not found", http.StatusNotFound)
return return
} }
http.Error(w, "Failed to delete file", http.StatusInternalServerError) log.Error("failed to delete file",
"filePath", filePath,
"error", err.Error(),
)
respondError(w, "Failed to delete file", http.StatusInternalServerError)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusNoContent)
_, err = w.Write([]byte("File deleted successfully"))
if err != nil {
http.Error(w, "Failed to write response", http.StatusInternalServerError)
return
}
} }
} }
// GetLastOpenedFile returns the last opened file in the workspace // GetLastOpenedFile godoc
// @Summary Get last opened file
// @Description Returns the path of the last opened file in the user's workspace
// @Tags files
// @ID getLastOpenedFile
// @Security CookieAuth
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Success 200 {object} LastOpenedFileResponse
// @Failure 400 {object} ErrorResponse "Invalid file path"
// @Failure 500 {object} ErrorResponse "Failed to get last opened file"
// @Router /workspaces/{workspace_name}/files/last [get]
func (h *Handler) GetLastOpenedFile() http.HandlerFunc { func (h *Handler) GetLastOpenedFile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getFilesLogger().With(
"handler", "GetLastOpenedFile",
"userID", ctx.UserID,
"workspaceID", ctx.Workspace.ID,
"clientIP", r.RemoteAddr,
)
filePath, err := h.DB.GetLastOpenedFile(ctx.Workspace.ID) filePath, err := h.DB.GetLastOpenedFile(ctx.Workspace.ID)
if err != nil { if err != nil {
http.Error(w, "Failed to get last opened file", http.StatusInternalServerError) log.Error("failed to get last opened file from database",
"error", err.Error(),
)
respondError(w, "Failed to get last opened file", http.StatusInternalServerError)
return return
} }
if _, err := h.Storage.ValidatePath(ctx.UserID, ctx.Workspace.ID, filePath); err != nil { if _, err := h.Storage.ValidatePath(ctx.UserID, ctx.Workspace.ID, filePath); err != nil {
http.Error(w, "Invalid file path", http.StatusBadRequest) log.Error("invalid file path stored",
"filePath", filePath,
"error", err.Error(),
)
respondError(w, "Invalid file path", http.StatusBadRequest)
return return
} }
respondJSON(w, map[string]string{"lastOpenedFilePath": filePath}) respondJSON(w, &LastOpenedFileResponse{LastOpenedFilePath: filePath})
} }
} }
// UpdateLastOpenedFile updates the last opened file in the workspace // UpdateLastOpenedFile godoc
// @Summary Update last opened file
// @Description Updates the last opened file in the user's workspace
// @Tags files
// @ID updateLastOpenedFile
// @Security CookieAuth
// @Accept json
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Param body body UpdateLastOpenedFileRequest true "Update last opened file request"
// @Success 204 "No Content - Last opened file updated successfully"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 400 {object} ErrorResponse "Invalid file path"
// @Failure 404 {object} ErrorResponse "File not found"
// @Failure 500 {object} ErrorResponse "Failed to update file"
// @Router /workspaces/{workspace_name}/files/last [put]
func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc { func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getFilesLogger().With(
"handler", "UpdateLastOpenedFile",
"userID", ctx.UserID,
"workspaceID", ctx.Workspace.ID,
"clientIP", r.RemoteAddr,
)
var requestBody struct { var requestBody UpdateLastOpenedFileRequest
FilePath string `json:"filePath"`
}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest) log.Error("failed to decode request body",
"error", err.Error(),
)
respondError(w, "Invalid request body", http.StatusBadRequest)
return return
} }
@@ -198,25 +416,40 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc {
_, err := h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, requestBody.FilePath) _, err := h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, requestBody.FilePath)
if err != nil { if err != nil {
if storage.IsPathValidationError(err) { if storage.IsPathValidationError(err) {
http.Error(w, "Invalid file path", http.StatusBadRequest) log.Error("invalid file path attempted",
"filePath", requestBody.FilePath,
"error", err.Error(),
)
respondError(w, "Invalid file path", http.StatusBadRequest)
return return
} }
if os.IsNotExist(err) { if os.IsNotExist(err) {
http.Error(w, "File not found", http.StatusNotFound) log.Debug("file not found",
"filePath", requestBody.FilePath,
)
respondError(w, "File not found", http.StatusNotFound)
return return
} }
http.Error(w, "Failed to update file", http.StatusInternalServerError) log.Error("failed to validate file path",
"filePath", requestBody.FilePath,
"error", err.Error(),
)
respondError(w, "Failed to update last opened file", http.StatusInternalServerError)
return return
} }
} }
if err := h.DB.UpdateLastOpenedFile(ctx.Workspace.ID, requestBody.FilePath); err != nil { if err := h.DB.UpdateLastOpenedFile(ctx.Workspace.ID, requestBody.FilePath); err != nil {
http.Error(w, "Failed to update last opened file", http.StatusInternalServerError) log.Error("failed to update last opened file in database",
"filePath", requestBody.FilePath,
"error", err.Error(),
)
respondError(w, "Failed to update last opened file", http.StatusInternalServerError)
return return
} }
respondJSON(w, map[string]string{"message": "Last opened file updated successfully"}) w.WriteHeader(http.StatusNoContent)
} }
} }

View File

@@ -10,8 +10,8 @@ import (
"strings" "strings"
"testing" "testing"
"novamd/internal/models" "lemma/internal/models"
"novamd/internal/storage" "lemma/internal/storage"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -24,10 +24,10 @@ func TestFileHandlers_Integration(t *testing.T) {
t.Run("file operations", func(t *testing.T) { t.Run("file operations", func(t *testing.T) {
// Setup: Create a workspace first // Setup: Create a workspace first
workspace := &models.Workspace{ workspace := &models.Workspace{
UserID: h.RegularUser.ID, UserID: h.RegularTestUser.session.UserID,
Name: "File Test Workspace", Name: "File Test Workspace",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
err := json.NewDecoder(rr.Body).Decode(workspace) err := json.NewDecoder(rr.Body).Decode(workspace)
@@ -37,7 +37,7 @@ func TestFileHandlers_Integration(t *testing.T) {
baseURL := fmt.Sprintf("/api/v1/workspaces/%s/files", url.PathEscape(workspace.Name)) baseURL := fmt.Sprintf("/api/v1/workspaces/%s/files", url.PathEscape(workspace.Name))
t.Run("list empty directory", func(t *testing.T) { t.Run("list empty directory", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, baseURL, nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, baseURL, nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var files []storage.FileNode var files []storage.FileNode
@@ -51,16 +51,16 @@ func TestFileHandlers_Integration(t *testing.T) {
filePath := "test.md" filePath := "test.md"
// Save file // Save file
rr := h.makeRequestRaw(t, http.MethodPost, baseURL+"/"+filePath, strings.NewReader(content), h.RegularToken, nil) rr := h.makeRequestRaw(t, http.MethodPost, baseURL+"/"+filePath, strings.NewReader(content), h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
// Get file content // Get file content
rr = h.makeRequest(t, http.MethodGet, baseURL+"/"+filePath, nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, baseURL+"/"+filePath, nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, content, rr.Body.String()) assert.Equal(t, content, rr.Body.String())
// List directory should now show the file // List directory should now show the file
rr = h.makeRequest(t, http.MethodGet, baseURL, nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, baseURL, nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var files []storage.FileNode var files []storage.FileNode
@@ -80,12 +80,12 @@ func TestFileHandlers_Integration(t *testing.T) {
// Create all files // Create all files
for path, content := range files { for path, content := range files {
rr := h.makeRequest(t, http.MethodPost, baseURL+"/"+path, content, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, baseURL+"/"+path, content, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
} }
// List all files // List all files
rr := h.makeRequest(t, http.MethodGet, baseURL, nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, baseURL, nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var fileNodes []storage.FileNode var fileNodes []storage.FileNode
@@ -116,11 +116,11 @@ func TestFileHandlers_Integration(t *testing.T) {
// Look up a file that exists in multiple locations // Look up a file that exists in multiple locations
filename := "readme.md" filename := "readme.md"
dupContent := "Another readme" dupContent := "Another readme"
rr := h.makeRequest(t, http.MethodPost, baseURL+"/projects/"+filename, dupContent, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, baseURL+"/projects/"+filename, dupContent, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
// Search for the file // Search for the file
rr = h.makeRequest(t, http.MethodGet, baseURL+"/lookup?filename="+filename, nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, baseURL+"/lookup?filename="+filename, nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var response struct { var response struct {
@@ -131,7 +131,7 @@ func TestFileHandlers_Integration(t *testing.T) {
assert.Len(t, response.Paths, 2) assert.Len(t, response.Paths, 2)
// Search for non-existent file // Search for non-existent file
rr = h.makeRequest(t, http.MethodGet, baseURL+"/lookup?filename=nonexistent.md", nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, baseURL+"/lookup?filename=nonexistent.md", nil, h.RegularTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
@@ -140,21 +140,21 @@ func TestFileHandlers_Integration(t *testing.T) {
content := "This file will be deleted" content := "This file will be deleted"
// Create file // Create file
rr := h.makeRequest(t, http.MethodPost, baseURL+"/"+filePath, content, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, baseURL+"/"+filePath, content, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
// Delete file // Delete file
rr = h.makeRequest(t, http.MethodDelete, baseURL+"/"+filePath, nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodDelete, baseURL+"/"+filePath, nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusNoContent, rr.Code)
// Verify file is gone // Verify file is gone
rr = h.makeRequest(t, http.MethodGet, baseURL+"/"+filePath, nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, baseURL+"/"+filePath, nil, h.RegularTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
t.Run("last opened file", func(t *testing.T) { t.Run("last opened file", func(t *testing.T) {
// Initially should be empty // Initially should be empty
rr := h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var response struct { var response struct {
@@ -170,11 +170,11 @@ func TestFileHandlers_Integration(t *testing.T) {
}{ }{
FilePath: "docs/readme.md", FilePath: "docs/readme.md",
} }
rr = h.makeRequest(t, http.MethodPut, baseURL+"/last", updateReq, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodPut, baseURL+"/last", updateReq, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusNoContent, rr.Code)
// Verify update // Verify update
rr = h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
err = json.NewDecoder(rr.Body).Decode(&response) err = json.NewDecoder(rr.Body).Decode(&response)
@@ -183,7 +183,7 @@ func TestFileHandlers_Integration(t *testing.T) {
// Test invalid file path // Test invalid file path
updateReq.FilePath = "nonexistent.md" updateReq.FilePath = "nonexistent.md"
rr = h.makeRequest(t, http.MethodPut, baseURL+"/last", updateReq, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodPut, baseURL+"/last", updateReq, h.RegularTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
@@ -204,12 +204,12 @@ func TestFileHandlers_Integration(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Test without token // Test without session
rr := h.makeRequest(t, tc.method, tc.path, tc.body, "", nil) rr := h.makeRequest(t, tc.method, tc.path, tc.body, nil)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
// Test with wrong user's token // Test with wrong user's session
rr = h.makeRequest(t, tc.method, tc.path, tc.body, h.AdminToken, nil) rr = h.makeRequest(t, tc.method, tc.path, tc.body, h.AdminTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
} }
@@ -226,11 +226,11 @@ func TestFileHandlers_Integration(t *testing.T) {
for _, path := range maliciousPaths { for _, path := range maliciousPaths {
t.Run(path, func(t *testing.T) { t.Run(path, func(t *testing.T) {
// Try to read // Try to read
rr := h.makeRequest(t, http.MethodGet, baseURL+"/"+path, nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, baseURL+"/"+path, nil, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
// Try to write // Try to write
rr = h.makeRequest(t, http.MethodPost, baseURL+"/"+path, "malicious content", h.RegularToken, nil) rr = h.makeRequest(t, http.MethodPost, baseURL+"/"+path, "malicious content", h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
}) })
} }

View File

@@ -2,57 +2,119 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"lemma/internal/context"
"lemma/internal/logging"
"net/http" "net/http"
"novamd/internal/context"
) )
// StageCommitAndPush stages, commits, and pushes changes to the remote repository // CommitRequest represents a request to commit changes
type CommitRequest struct {
Message string `json:"message" example:"Initial commit"`
}
// CommitResponse represents a response to a commit request
type CommitResponse struct {
CommitHash string `json:"commitHash" example:"a1b2c3d4"`
}
// PullResponse represents a response to a pull http request
type PullResponse struct {
Message string `json:"message" example:"Pulled changes from remote"`
}
func getGitLogger() logging.Logger {
return getHandlersLogger().WithGroup("git")
}
// StageCommitAndPush godoc
// @Summary Stage, commit, and push changes
// @Description Stages, commits, and pushes changes to the remote repository
// @Tags git
// @ID stageCommitAndPush
// @Security CookieAuth
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Param body body CommitRequest true "Commit request"
// @Success 200 {object} CommitResponse
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 400 {object} ErrorResponse "Commit message is required"
// @Failure 500 {object} ErrorResponse "Failed to stage, commit, and push changes"
// @Router /workspaces/{workspace_name}/git/commit [post]
func (h *Handler) StageCommitAndPush() http.HandlerFunc { func (h *Handler) StageCommitAndPush() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getGitLogger().With(
"handler", "StageCommitAndPush",
"userID", ctx.UserID,
"workspaceID", ctx.Workspace.ID,
"clientIP", r.RemoteAddr,
)
var requestBody struct { var requestBody CommitRequest
Message string `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest) log.Error("failed to decode request body",
"error", err.Error(),
)
respondError(w, "Invalid request body", http.StatusBadRequest)
return return
} }
if requestBody.Message == "" { if requestBody.Message == "" {
http.Error(w, "Commit message is required", http.StatusBadRequest) log.Debug("empty commit message provided")
respondError(w, "Commit message is required", http.StatusBadRequest)
return return
} }
err := h.Storage.StageCommitAndPush(ctx.UserID, ctx.Workspace.ID, requestBody.Message) hash, err := h.Storage.StageCommitAndPush(ctx.UserID, ctx.Workspace.ID, requestBody.Message)
if err != nil { if err != nil {
http.Error(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError) log.Error("failed to perform git operations",
"error", err.Error(),
"commitMessage", requestBody.Message,
)
respondError(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError)
return return
} }
respondJSON(w, map[string]string{"message": "Changes staged, committed, and pushed successfully"}) respondJSON(w, CommitResponse{CommitHash: hash.String()})
} }
} }
// PullChanges pulls changes from the remote repository // PullChanges godoc
// @Summary Pull changes from remote
// @Description Pulls changes from the remote repository
// @Tags git
// @ID pullChanges
// @Security CookieAuth
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Success 200 {object} PullResponse
// @Failure 500 {object} ErrorResponse "Failed to pull changes"
// @Router /workspaces/{workspace_name}/git/pull [post]
func (h *Handler) PullChanges() http.HandlerFunc { func (h *Handler) PullChanges() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getGitLogger().With(
"handler", "PullChanges",
"userID", ctx.UserID,
"workspaceID", ctx.Workspace.ID,
"clientIP", r.RemoteAddr,
)
err := h.Storage.Pull(ctx.UserID, ctx.Workspace.ID) err := h.Storage.Pull(ctx.UserID, ctx.Workspace.ID)
if err != nil { if err != nil {
http.Error(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError) log.Error("failed to pull changes from remote",
"error", err.Error(),
)
respondError(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError)
return return
} }
respondJSON(w, map[string]string{"message": "Pulled changes from remote"}) respondJSON(w, PullResponse{Message: "Successfully pulled changes from remote"})
} }
} }

View File

@@ -9,7 +9,7 @@ import (
"net/url" "net/url"
"testing" "testing"
"novamd/internal/models" "lemma/internal/models"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -22,7 +22,7 @@ func TestGitHandlers_Integration(t *testing.T) {
t.Run("git operations", func(t *testing.T) { t.Run("git operations", func(t *testing.T) {
// Setup: Create a workspace with Git enabled // Setup: Create a workspace with Git enabled
workspace := &models.Workspace{ workspace := &models.Workspace{
UserID: h.RegularUser.ID, UserID: h.RegularTestUser.session.UserID,
Name: "Git Test Workspace", Name: "Git Test Workspace",
GitEnabled: true, GitEnabled: true,
GitURL: "https://github.com/test/repo.git", GitURL: "https://github.com/test/repo.git",
@@ -32,7 +32,7 @@ func TestGitHandlers_Integration(t *testing.T) {
GitCommitMsgTemplate: "Update: {{message}}", GitCommitMsgTemplate: "Update: {{message}}",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
err := json.NewDecoder(rr.Body).Decode(workspace) err := json.NewDecoder(rr.Body).Decode(workspace)
@@ -50,13 +50,13 @@ func TestGitHandlers_Integration(t *testing.T) {
"message": commitMsg, "message": commitMsg,
} }
rr := h.makeRequest(t, http.MethodPost, baseURL+"/commit", requestBody, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, baseURL+"/commit", requestBody, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var response map[string]string var response map[string]string
err := json.NewDecoder(rr.Body).Decode(&response) err := json.NewDecoder(rr.Body).Decode(&response)
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, response["message"], "successfully") require.Contains(t, response, "commitHash")
// Verify mock was called correctly // Verify mock was called correctly
assert.Equal(t, 1, h.MockGit.GetCommitCount(), "Commit should be called once") assert.Equal(t, 1, h.MockGit.GetCommitCount(), "Commit should be called once")
@@ -70,7 +70,7 @@ func TestGitHandlers_Integration(t *testing.T) {
"message": "", "message": "",
} }
rr := h.makeRequest(t, http.MethodPost, baseURL+"/commit", requestBody, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, baseURL+"/commit", requestBody, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
assert.Equal(t, 0, h.MockGit.GetCommitCount(), "Commit should not be called") assert.Equal(t, 0, h.MockGit.GetCommitCount(), "Commit should not be called")
}) })
@@ -83,7 +83,7 @@ func TestGitHandlers_Integration(t *testing.T) {
"message": "Test message", "message": "Test message",
} }
rr := h.makeRequest(t, http.MethodPost, baseURL+"/commit", requestBody, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, baseURL+"/commit", requestBody, h.RegularTestUser)
assert.Equal(t, http.StatusInternalServerError, rr.Code) assert.Equal(t, http.StatusInternalServerError, rr.Code)
h.MockGit.SetError(nil) // Reset error state h.MockGit.SetError(nil) // Reset error state
@@ -94,13 +94,13 @@ func TestGitHandlers_Integration(t *testing.T) {
h.MockGit.Reset() h.MockGit.Reset()
t.Run("successful pull", func(t *testing.T) { t.Run("successful pull", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodPost, baseURL+"/pull", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, baseURL+"/pull", nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var response map[string]string var response map[string]string
err := json.NewDecoder(rr.Body).Decode(&response) err := json.NewDecoder(rr.Body).Decode(&response)
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, response["message"], "Pulled changes") assert.Contains(t, response["message"], "Successfully pulled changes")
assert.Equal(t, 1, h.MockGit.GetPullCount(), "Pull should be called once") assert.Equal(t, 1, h.MockGit.GetPullCount(), "Pull should be called once")
}) })
@@ -109,7 +109,7 @@ func TestGitHandlers_Integration(t *testing.T) {
h.MockGit.Reset() h.MockGit.Reset()
h.MockGit.SetError(fmt.Errorf("mock git error")) h.MockGit.SetError(fmt.Errorf("mock git error"))
rr := h.makeRequest(t, http.MethodPost, baseURL+"/pull", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, baseURL+"/pull", nil, h.RegularTestUser)
assert.Equal(t, http.StatusInternalServerError, rr.Code) assert.Equal(t, http.StatusInternalServerError, rr.Code)
h.MockGit.SetError(nil) // Reset error state h.MockGit.SetError(nil) // Reset error state
@@ -140,12 +140,12 @@ func TestGitHandlers_Integration(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Test without token // Test without session
rr := h.makeRequest(t, tc.method, tc.path, tc.body, "", nil) rr := h.makeRequest(t, tc.method, tc.path, tc.body, nil)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
// Test with wrong user's token // Test with wrong user's session
rr = h.makeRequest(t, tc.method, tc.path, tc.body, h.AdminToken, nil) rr = h.makeRequest(t, tc.method, tc.path, tc.body, h.AdminTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
} }
@@ -156,11 +156,11 @@ func TestGitHandlers_Integration(t *testing.T) {
// Create a workspace without Git enabled // Create a workspace without Git enabled
nonGitWorkspace := &models.Workspace{ nonGitWorkspace := &models.Workspace{
UserID: h.RegularUser.ID, UserID: h.RegularTestUser.session.UserID,
Name: "Non-Git Workspace", Name: "Non-Git Workspace",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", nonGitWorkspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", nonGitWorkspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
err := json.NewDecoder(rr.Body).Decode(nonGitWorkspace) err := json.NewDecoder(rr.Body).Decode(nonGitWorkspace)
@@ -170,11 +170,11 @@ func TestGitHandlers_Integration(t *testing.T) {
// Try to commit // Try to commit
commitMsg := map[string]string{"message": "test"} commitMsg := map[string]string{"message": "test"}
rr = h.makeRequest(t, http.MethodPost, nonGitBaseURL+"/commit", commitMsg, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodPost, nonGitBaseURL+"/commit", commitMsg, h.RegularTestUser)
assert.Equal(t, http.StatusInternalServerError, rr.Code) assert.Equal(t, http.StatusInternalServerError, rr.Code)
// Try to pull // Try to pull
rr = h.makeRequest(t, http.MethodPost, nonGitBaseURL+"/pull", nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodPost, nonGitBaseURL+"/pull", nil, h.RegularTestUser)
assert.Equal(t, http.StatusInternalServerError, rr.Code) assert.Equal(t, http.StatusInternalServerError, rr.Code)
}) })
}) })

View File

@@ -2,17 +2,32 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"lemma/internal/db"
"lemma/internal/logging"
"lemma/internal/storage"
"net/http" "net/http"
"novamd/internal/db"
"novamd/internal/storage"
) )
// ErrorResponse is a generic error response
type ErrorResponse struct {
Message string `json:"message"`
}
// Handler provides common functionality for all handlers // Handler provides common functionality for all handlers
type Handler struct { type Handler struct {
DB db.Database DB db.Database
Storage storage.Manager Storage storage.Manager
} }
var logger logging.Logger
func getHandlersLogger() logging.Logger {
if logger == nil {
logger = logging.WithGroup("handlers")
}
return logger
}
// NewHandler creates a new handler with the given dependencies // NewHandler creates a new handler with the given dependencies
func NewHandler(db db.Database, s storage.Manager) *Handler { func NewHandler(db db.Database, s storage.Manager) *Handler {
return &Handler{ return &Handler{
@@ -25,6 +40,12 @@ func NewHandler(db db.Database, s storage.Manager) *Handler {
func respondJSON(w http.ResponseWriter, data interface{}) { func respondJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil { if err := json.NewEncoder(w).Encode(data); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) respondError(w, "Failed to encode response", http.StatusInternalServerError)
} }
} }
// respondError is a helper to send error responses
func respondError(w http.ResponseWriter, message string, code int) {
w.WriteHeader(code)
respondJSON(w, ErrorResponse{Message: message})
}

View File

@@ -6,38 +6,43 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"io" "io"
"net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"testing" "testing"
"time" "time"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"novamd/internal/api" "lemma/internal/app"
"novamd/internal/auth" "lemma/internal/auth"
"novamd/internal/db" "lemma/internal/db"
"novamd/internal/git" "lemma/internal/git"
"novamd/internal/handlers" "lemma/internal/models"
"novamd/internal/models" "lemma/internal/secrets"
"novamd/internal/secrets" "lemma/internal/storage"
"novamd/internal/storage"
_ "lemma/internal/testenv"
) )
// testHarness encapsulates all the dependencies needed for testing // testHarness encapsulates all the dependencies needed for testing
type testHarness struct { type testHarness struct {
DB db.TestDatabase Server *app.Server
Storage storage.Manager DB db.TestDatabase
Router *chi.Mux Storage storage.Manager
Handler *handlers.Handler JWTManager auth.JWTManager
JWTManager auth.JWTManager SessionManager auth.SessionManager
SessionSvc *auth.SessionService CookieManager auth.CookieManager
AdminUser *models.User AdminTestUser *testUser
AdminToken string RegularTestUser *testUser
RegularUser *models.User TempDirectory string
RegularToken string MockGit *MockGitClient
TempDirectory string }
MockGit *MockGitClient
type testUser struct {
userModel *models.User
accessToken string
session *models.Session
} }
// setupTestHarness creates a new test environment // setupTestHarness creates a new test environment
@@ -45,7 +50,7 @@ func setupTestHarness(t *testing.T) *testHarness {
t.Helper() t.Helper()
// Create temporary directory for test files // Create temporary directory for test files
tempDir, err := os.MkdirTemp("", "novamd-test-*") tempDir, err := os.MkdirTemp("", "lemma-test-*")
if err != nil { if err != nil {
t.Fatalf("Failed to create temp directory: %v", err) t.Fatalf("Failed to create temp directory: %v", err)
} }
@@ -89,38 +94,51 @@ func setupTestHarness(t *testing.T) *testHarness {
// Initialize session service // Initialize session service
sessionSvc := auth.NewSessionService(database, jwtSvc) sessionSvc := auth.NewSessionService(database, jwtSvc)
// Create handler // Initialize cookie service
handler := &handlers.Handler{ cookieSvc := auth.NewCookieService(true, "localhost")
DB: database,
Storage: storageSvc, // Create test config
testConfig := &app.Config{
DBPath: ":memory:",
WorkDir: tempDir,
StaticPath: "../testdata",
Port: "8081",
AdminEmail: "admin@test.com",
AdminPassword: "admin123",
EncryptionKey: "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=",
IsDevelopment: true,
} }
// Set up router with middlewares // Create server options
router := chi.NewRouter() serverOpts := &app.Options{
authMiddleware := auth.NewMiddleware(jwtSvc) Config: testConfig,
router.Route("/api/v1", func(r chi.Router) { Database: database,
api.SetupRoutes(r, database, storageSvc, authMiddleware, sessionSvc) Storage: storageSvc,
}) JWTManager: jwtSvc,
SessionManager: sessionSvc,
CookieService: cookieSvc,
}
// Create server
srv := app.NewServer(serverOpts)
h := &testHarness{ h := &testHarness{
DB: database, Server: srv,
Storage: storageSvc, DB: database,
Router: router, Storage: storageSvc,
Handler: handler, JWTManager: jwtSvc,
JWTManager: jwtSvc, SessionManager: sessionSvc,
SessionSvc: sessionSvc, CookieManager: cookieSvc,
TempDirectory: tempDir, TempDirectory: tempDir,
MockGit: mockGit, MockGit: mockGit,
} }
// Create test users // Create test users
adminUser, adminToken := h.createTestUser(t, database, sessionSvc, "admin@test.com", "admin123", models.RoleAdmin) adminTestUser := h.createTestUser(t, "admin@test.com", "admin123", models.RoleAdmin)
regularUser, regularToken := h.createTestUser(t, database, sessionSvc, "user@test.com", "user123", models.RoleEditor) regularTestUser := h.createTestUser(t, "user@test.com", "user123", models.RoleEditor)
h.AdminUser = adminUser h.AdminTestUser = adminTestUser
h.AdminToken = adminToken h.RegularTestUser = regularTestUser
h.RegularUser = regularUser
h.RegularToken = regularToken
return h return h
} }
@@ -139,7 +157,7 @@ func (h *testHarness) teardown(t *testing.T) {
} }
// createTestUser creates a test user and returns the user and access token // createTestUser creates a test user and returns the user and access token
func (h *testHarness) createTestUser(t *testing.T, db db.Database, sessionSvc *auth.SessionService, email, password string, role models.UserRole) (*models.User, string) { func (h *testHarness) createTestUser(t *testing.T, email, password string, role models.UserRole) *testUser {
t.Helper() t.Helper()
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
@@ -154,7 +172,7 @@ func (h *testHarness) createTestUser(t *testing.T, db db.Database, sessionSvc *a
Role: role, Role: role,
} }
user, err = db.CreateUser(user) user, err = h.DB.CreateUser(user)
if err != nil { if err != nil {
t.Fatalf("Failed to create user: %v", err) t.Fatalf("Failed to create user: %v", err)
} }
@@ -165,25 +183,23 @@ func (h *testHarness) createTestUser(t *testing.T, db db.Database, sessionSvc *a
t.Fatalf("Failed to initialize user workspace: %v", err) t.Fatalf("Failed to initialize user workspace: %v", err)
} }
session, accessToken, err := sessionSvc.CreateSession(user.ID, string(user.Role)) session, accessToken, err := h.SessionManager.CreateSession(user.ID, string(user.Role))
if err != nil { if err != nil {
t.Fatalf("Failed to create session: %v", err) t.Fatalf("Failed to create session: %v", err)
} }
if session == nil || accessToken == "" { return &testUser{
t.Fatal("Failed to get valid session or token") userModel: user,
accessToken: accessToken,
session: session,
} }
return user, accessToken
} }
// makeRequest is a helper function to make HTTP requests in tests func (h *testHarness) newRequest(t *testing.T, method, path string, body interface{}) *http.Request {
func (h *testHarness) makeRequest(t *testing.T, method, path string, body interface{}, token string, headers map[string]string) *httptest.ResponseRecorder {
t.Helper() t.Helper()
var reqBody []byte var reqBody []byte
var err error var err error
if body != nil { if body != nil {
reqBody, err = json.Marshal(body) reqBody, err = json.Marshal(body)
if err != nil { if err != nil {
@@ -192,38 +208,71 @@ func (h *testHarness) makeRequest(t *testing.T, method, path string, body interf
} }
req := httptest.NewRequest(method, path, bytes.NewBuffer(reqBody)) req := httptest.NewRequest(method, path, bytes.NewBuffer(reqBody))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
return req
}
// Add any additional headers // newRequestRaw creates a new request with raw body
for k, v := range headers { func (h *testHarness) newRequestRaw(t *testing.T, method, path string, body io.Reader) *http.Request {
req.Header.Set(k, v) t.Helper()
} return httptest.NewRequest(method, path, body)
}
// executeRequest executes the request and returns response recorder
func (h *testHarness) executeRequest(req *http.Request) *httptest.ResponseRecorder {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
h.Router.ServeHTTP(rr, req) h.Server.Router().ServeHTTP(rr, req)
return rr return rr
} }
// makeRequestRaw is a helper function to make HTTP requests with raw body content // addAuthCookies adds authentication cookies to request
func (h *testHarness) makeRequestRaw(t *testing.T, method, path string, body io.Reader, token string, headers map[string]string) *httptest.ResponseRecorder { func (h *testHarness) addAuthCookies(t *testing.T, req *http.Request, testUser *testUser) {
t.Helper() t.Helper()
req := httptest.NewRequest(method, path, body) if testUser == nil || testUser.session == nil {
if token != "" { return
req.Header.Set("Authorization", "Bearer "+token)
} }
// Add any additional headers req.AddCookie(h.CookieManager.GenerateAccessTokenCookie(testUser.accessToken))
for k, v := range headers { req.AddCookie(h.CookieManager.GenerateRefreshTokenCookie(testUser.session.RefreshToken))
req.Header.Set(k, v) }
}
func (h *testHarness) addCSRFCookie(t *testing.T, req *http.Request) string {
rr := httptest.NewRecorder() t.Helper()
h.Router.ServeHTTP(rr, req)
csrfToken := "test-csrf-token"
return rr req.AddCookie(h.CookieManager.GenerateCSRFCookie(csrfToken))
return csrfToken
}
// makeRequest is the main helper for making JSON requests
func (h *testHarness) makeRequest(t *testing.T, method, path string, body interface{}, testUser *testUser) *httptest.ResponseRecorder {
t.Helper()
req := h.newRequest(t, method, path, body)
h.addAuthCookies(t, req, testUser)
needsCSRF := method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions
if needsCSRF {
csrfToken := h.addCSRFCookie(t, req)
req.Header.Set("X-CSRF-Token", csrfToken)
}
return h.executeRequest(req)
}
// makeRequestRawWithHeaders adds support for custom headers with raw body
func (h *testHarness) makeRequestRaw(t *testing.T, method, path string, body io.Reader, testUser *testUser) *httptest.ResponseRecorder {
t.Helper()
req := h.newRequestRaw(t, method, path, body)
h.addAuthCookies(t, req, testUser)
needsCSRF := method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions
if needsCSRF {
csrfToken := h.addCSRFCookie(t, req)
req.Header.Set("X-CSRF-Token", csrfToken)
}
return h.executeRequest(req)
} }

View File

@@ -4,6 +4,7 @@ package handlers_test
import ( import (
"fmt" "fmt"
"lemma/internal/git"
) )
// MockGitClient implements the git.Client interface for testing // MockGitClient implements the git.Client interface for testing
@@ -51,13 +52,13 @@ func (m *MockGitClient) Pull() error {
} }
// Commit implements git.Client // Commit implements git.Client
func (m *MockGitClient) Commit(message string) error { func (m *MockGitClient) Commit(message string) (git.CommitHash, error) {
if m.error != nil { if m.error != nil {
return m.error return git.CommitHash{}, m.error
} }
m.commitCount++ m.commitCount++
m.lastCommitMsg = message m.lastCommitMsg = message
return nil return git.CommitHash{}, nil
} }
// Push implements git.Client // Push implements git.Client

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"lemma/internal/logging"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -19,8 +20,19 @@ func NewStaticHandler(staticPath string) *StaticHandler {
} }
} }
func getStaticLogger() logging.Logger {
return logging.WithGroup("static")
}
// ServeHTTP serves the static files // ServeHTTP serves the static files
func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log := getStaticLogger().With(
"handler", "ServeHTTP",
"clientIP", r.RemoteAddr,
"method", r.Method,
"url", r.URL.Path,
)
// Get the requested path // Get the requested path
requestedPath := r.URL.Path requestedPath := r.URL.Path
fullPath := filepath.Join(h.staticPath, requestedPath) fullPath := filepath.Join(h.staticPath, requestedPath)
@@ -28,7 +40,11 @@ func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Security check to prevent directory traversal // Security check to prevent directory traversal
if !strings.HasPrefix(cleanPath, h.staticPath) { if !strings.HasPrefix(cleanPath, h.staticPath) {
http.Error(w, "Invalid path", http.StatusBadRequest) log.Warn("directory traversal attempt detected",
"requestedPath", requestedPath,
"cleanPath", cleanPath,
)
respondError(w, "Invalid path", http.StatusBadRequest)
return return
} }
@@ -40,6 +56,21 @@ func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Check if file exists (not counting .gz files) // Check if file exists (not counting .gz files)
stat, err := os.Stat(cleanPath) stat, err := os.Stat(cleanPath)
if err != nil || stat.IsDir() { if err != nil || stat.IsDir() {
if os.IsNotExist(err) {
log.Debug("file not found, serving index.html",
"requestedPath", requestedPath,
)
} else if stat != nil && stat.IsDir() {
log.Debug("directory requested, serving index.html",
"requestedPath", requestedPath,
)
} else {
log.Error("error checking file status",
"requestedPath", requestedPath,
"error", err.Error(),
)
}
// Serve index.html for SPA routing // Serve index.html for SPA routing
indexPath := filepath.Join(h.staticPath, "index.html") indexPath := filepath.Join(h.staticPath, "index.html")
http.ServeFile(w, r, indexPath) http.ServeFile(w, r, indexPath)
@@ -53,15 +84,16 @@ func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Encoding", "gzip") w.Header().Set("Content-Encoding", "gzip")
// Set proper content type based on original file // Set proper content type based on original file
contentType := "application/octet-stream"
switch filepath.Ext(cleanPath) { switch filepath.Ext(cleanPath) {
case ".js": case ".js":
w.Header().Set("Content-Type", "application/javascript") contentType = "application/javascript"
case ".css": case ".css":
w.Header().Set("Content-Type", "text/css") contentType = "text/css"
case ".html": case ".html":
w.Header().Set("Content-Type", "text/html") contentType = "text/html"
} }
w.Header().Set("Content-Type", contentType)
http.ServeFile(w, r, gzPath) http.ServeFile(w, r, gzPath)
return return
} }

View File

@@ -9,7 +9,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"novamd/internal/handlers" "lemma/internal/handlers"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -17,7 +17,7 @@ import (
func TestStaticHandler_Integration(t *testing.T) { func TestStaticHandler_Integration(t *testing.T) {
// Create temporary directory for test static files // Create temporary directory for test static files
tempDir, err := os.MkdirTemp("", "novamd-static-test-*") tempDir, err := os.MkdirTemp("", "lemmastatic-test-*")
require.NoError(t, err) require.NoError(t, err)
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)

View File

@@ -4,7 +4,8 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"novamd/internal/context" "lemma/internal/context"
"lemma/internal/logging"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -22,134 +23,214 @@ type DeleteAccountRequest struct {
Password string `json:"password"` Password string `json:"password"`
} }
// UpdateProfile updates the current user's profile func getProfileLogger() logging.Logger {
return getHandlersLogger().WithGroup("profile")
}
// UpdateProfile godoc
// @Summary Update profile
// @Description Updates the user's profile
// @Tags users
// @ID updateProfile
// @Security CookieAuth
// @Accept json
// @Produce json
// @Param body body UpdateProfileRequest true "Profile update request"
// @Success 200 {object} models.User
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 400 {object} ErrorResponse "Current password is required to change password"
// @Failure 400 {object} ErrorResponse "New password must be at least 8 characters long"
// @Failure 400 {object} ErrorResponse "Current password is required to change email"
// @Failure 401 {object} ErrorResponse "Current password is incorrect"
// @Failure 404 {object} ErrorResponse "User not found"
// @Failure 409 {object} ErrorResponse "Email already in use"
// @Failure 500 {object} ErrorResponse "Failed to process new password"
// @Failure 500 {object} ErrorResponse "Failed to update profile"
// @Router /profile [put]
func (h *Handler) UpdateProfile() http.HandlerFunc { func (h *Handler) UpdateProfile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getProfileLogger().With(
"handler", "UpdateProfile",
"userID", ctx.UserID,
"clientIP", r.RemoteAddr,
)
var req UpdateProfileRequest var req UpdateProfileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest) log.Debug("failed to decode request body",
"error", err.Error(),
)
respondError(w, "Invalid request body", http.StatusBadRequest)
return return
} }
// Get current user // Get current user
user, err := h.DB.GetUserByID(ctx.UserID) user, err := h.DB.GetUserByID(ctx.UserID)
if err != nil { if err != nil {
http.Error(w, "User not found", http.StatusNotFound) log.Error("failed to fetch user from database",
"error", err.Error(),
)
respondError(w, "User not found", http.StatusNotFound)
return return
} }
// Track what's being updated for logging
updates := make(map[string]bool)
// Handle password update if requested // Handle password update if requested
if req.NewPassword != "" { if req.NewPassword != "" {
// Current password must be provided to change password
if req.CurrentPassword == "" { if req.CurrentPassword == "" {
http.Error(w, "Current password is required to change password", http.StatusBadRequest) log.Debug("password change attempted without current password")
respondError(w, "Current password is required to change password", http.StatusBadRequest)
return return
} }
// Verify current password
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil {
http.Error(w, "Current password is incorrect", http.StatusUnauthorized) log.Warn("incorrect password provided for password change")
respondError(w, "Current password is incorrect", http.StatusUnauthorized)
return return
} }
// Validate new password
if len(req.NewPassword) < 8 { if len(req.NewPassword) < 8 {
http.Error(w, "New password must be at least 8 characters long", http.StatusBadRequest) log.Debug("password change rejected - too short",
"passwordLength", len(req.NewPassword),
)
respondError(w, "New password must be at least 8 characters long", http.StatusBadRequest)
return return
} }
// Hash new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil { if err != nil {
http.Error(w, "Failed to process new password", http.StatusInternalServerError) log.Error("failed to hash new password",
"error", err.Error(),
)
respondError(w, "Failed to process new password", http.StatusInternalServerError)
return return
} }
user.PasswordHash = string(hashedPassword) user.PasswordHash = string(hashedPassword)
updates["passwordChanged"] = true
} }
// Handle email update if requested // Handle email update if requested
if req.Email != "" && req.Email != user.Email { if req.Email != "" && req.Email != user.Email {
// Check if email change requires password verification
if req.CurrentPassword == "" { if req.CurrentPassword == "" {
http.Error(w, "Current password is required to change email", http.StatusBadRequest) log.Warn("attempted email change without current password")
respondError(w, "Current password is required to change email", http.StatusBadRequest)
return return
} }
// Verify current password if not already verified for password change
if req.NewPassword == "" { if req.NewPassword == "" {
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil {
http.Error(w, "Current password is incorrect", http.StatusUnauthorized) log.Warn("incorrect password provided for email change")
respondError(w, "Current password is incorrect", http.StatusUnauthorized)
return return
} }
} }
// Check if new email is already in use
existingUser, err := h.DB.GetUserByEmail(req.Email) existingUser, err := h.DB.GetUserByEmail(req.Email)
if err == nil && existingUser.ID != user.ID { if err == nil && existingUser.ID != user.ID {
http.Error(w, "Email already in use", http.StatusConflict) log.Debug("email change rejected - already in use",
"requestedEmail", req.Email,
)
respondError(w, "Email already in use", http.StatusConflict)
return return
} }
user.Email = req.Email user.Email = req.Email
updates["emailChanged"] = true
} }
// Update display name if provided (no password required) // Update display name if provided
if req.DisplayName != "" { if req.DisplayName != "" {
user.DisplayName = req.DisplayName user.DisplayName = req.DisplayName
updates["displayNameChanged"] = true
} }
// Update user in database // Update user in database
if err := h.DB.UpdateUser(user); err != nil { if err := h.DB.UpdateUser(user); err != nil {
http.Error(w, "Failed to update profile", http.StatusInternalServerError) log.Error("failed to update user in database",
"error", err.Error(),
"updates", updates,
)
respondError(w, "Failed to update profile", http.StatusInternalServerError)
return return
} }
// Return updated user data
respondJSON(w, user) respondJSON(w, user)
} }
} }
// DeleteAccount handles user account deletion // DeleteAccount godoc
// @Summary Delete account
// @Description Deletes the user's account and all associated data
// @Tags users
// @ID deleteAccount
// @Security CookieAuth
// @Accept json
// @Produce json
// @Param body body DeleteAccountRequest true "Account deletion request"
// @Success 204 "No Content - Account deleted successfully"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 401 {object} ErrorResponse "Password is incorrect"
// @Failure 403 {object} ErrorResponse "Cannot delete the last admin account"
// @Failure 404 {object} ErrorResponse "User not found"
// @Failure 500 {object} ErrorResponse "Failed to verify admin status"
// @Failure 500 {object} ErrorResponse "Failed to delete account"
// @Router /profile [delete]
func (h *Handler) DeleteAccount() http.HandlerFunc { func (h *Handler) DeleteAccount() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getProfileLogger().With(
"handler", "DeleteAccount",
"userID", ctx.UserID,
"clientIP", r.RemoteAddr,
)
var req DeleteAccountRequest var req DeleteAccountRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest) log.Debug("failed to decode request body",
"error", err.Error(),
)
respondError(w, "Invalid request body", http.StatusBadRequest)
return return
} }
// Get current user // Get current user
user, err := h.DB.GetUserByID(ctx.UserID) user, err := h.DB.GetUserByID(ctx.UserID)
if err != nil { if err != nil {
http.Error(w, "User not found", http.StatusNotFound) log.Error("failed to fetch user from database",
"error", err.Error(),
)
respondError(w, "User not found", http.StatusNotFound)
return return
} }
// Verify password // Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
http.Error(w, "Password is incorrect", http.StatusUnauthorized) log.Warn("incorrect password provided for account deletion")
respondError(w, "Incorrect password", http.StatusUnauthorized)
return return
} }
// Prevent admin from deleting their own account if they're the last admin // Prevent admin from deleting their own account if they're the last admin
if user.Role == "admin" { if user.Role == "admin" {
// Count number of admin users
adminCount, err := h.DB.CountAdminUsers() adminCount, err := h.DB.CountAdminUsers()
if err != nil { if err != nil {
http.Error(w, "Failed to verify admin status", http.StatusInternalServerError) log.Error("failed to count admin users",
"error", err.Error(),
)
respondError(w, "Failed to get admin count", http.StatusInternalServerError)
return return
} }
if adminCount <= 1 { if adminCount <= 1 {
http.Error(w, "Cannot delete the last admin account", http.StatusForbidden) log.Warn("attempted to delete last admin account")
respondError(w, "Cannot delete the last admin account", http.StatusForbidden)
return return
} }
} }
@@ -157,24 +238,41 @@ func (h *Handler) DeleteAccount() http.HandlerFunc {
// Get user's workspaces for cleanup // Get user's workspaces for cleanup
workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID) workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID)
if err != nil { if err != nil {
http.Error(w, "Failed to get user workspaces", http.StatusInternalServerError) log.Error("failed to fetch user workspaces",
"error", err.Error(),
)
respondError(w, "Failed to get user workspaces", http.StatusInternalServerError)
return return
} }
// Delete workspace directories // Delete workspace directories
for _, workspace := range workspaces { for _, workspace := range workspaces {
if err := h.Storage.DeleteUserWorkspace(ctx.UserID, workspace.ID); err != nil { if err := h.Storage.DeleteUserWorkspace(ctx.UserID, workspace.ID); err != nil {
http.Error(w, "Failed to delete workspace files", http.StatusInternalServerError) log.Error("failed to delete workspace directory",
"error", err.Error(),
"workspaceID", workspace.ID,
)
respondError(w, "Failed to delete workspace files", http.StatusInternalServerError)
return return
} }
log.Debug("workspace deleted",
"workspaceID", workspace.ID,
)
} }
// Delete user from database (this will cascade delete workspaces and sessions) // Delete user from database
if err := h.DB.DeleteUser(ctx.UserID); err != nil { if err := h.DB.DeleteUser(ctx.UserID); err != nil {
http.Error(w, "Failed to delete account", http.StatusInternalServerError) log.Error("failed to delete user from database",
"error", err.Error(),
)
respondError(w, "Failed to delete account", http.StatusInternalServerError)
return return
} }
respondJSON(w, map[string]string{"message": "Account deleted successfully"}) log.Info("user account deleted",
"email", user.Email,
"role", user.Role,
)
w.WriteHeader(http.StatusNoContent)
} }
} }

View File

@@ -7,8 +7,8 @@ import (
"net/http" "net/http"
"testing" "testing"
"novamd/internal/handlers" "lemma/internal/handlers"
"novamd/internal/models" "lemma/internal/models"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -18,27 +18,27 @@ func TestUserHandlers_Integration(t *testing.T) {
h := setupTestHarness(t) h := setupTestHarness(t)
defer h.teardown(t) defer h.teardown(t)
currentEmail := h.RegularUser.Email currentEmail := h.RegularTestUser.userModel.Email
currentPassword := "user123" currentPassword := "user123"
t.Run("get current user", func(t *testing.T) { t.Run("get current user", func(t *testing.T) {
t.Run("successful get", func(t *testing.T) { t.Run("successful get", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var user models.User var user models.User
err := json.NewDecoder(rr.Body).Decode(&user) err := json.NewDecoder(rr.Body).Decode(&user)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, h.RegularUser.ID, user.ID) assert.Equal(t, h.RegularTestUser.userModel.ID, user.ID)
assert.Equal(t, h.RegularUser.Email, user.Email) assert.Equal(t, h.RegularTestUser.userModel.Email, user.Email)
assert.Equal(t, h.RegularUser.DisplayName, user.DisplayName) assert.Equal(t, h.RegularTestUser.userModel.DisplayName, user.DisplayName)
assert.Equal(t, h.RegularUser.Role, user.Role) assert.Equal(t, h.RegularTestUser.userModel.Role, user.Role)
assert.Empty(t, user.PasswordHash, "Password hash should not be included in response") assert.Empty(t, user.PasswordHash, "Password hash should not be included in response")
}) })
t.Run("unauthorized", func(t *testing.T) { t.Run("unauthorized", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, "", nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, nil)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
}) })
}) })
@@ -49,7 +49,7 @@ func TestUserHandlers_Integration(t *testing.T) {
DisplayName: "Updated Name", DisplayName: "Updated Name",
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var user models.User var user models.User
@@ -64,7 +64,7 @@ func TestUserHandlers_Integration(t *testing.T) {
CurrentPassword: currentPassword, CurrentPassword: currentPassword,
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var user models.User var user models.User
@@ -80,7 +80,7 @@ func TestUserHandlers_Integration(t *testing.T) {
Email: "anotheremail@test.com", Email: "anotheremail@test.com",
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
}) })
@@ -90,7 +90,7 @@ func TestUserHandlers_Integration(t *testing.T) {
CurrentPassword: "wrongpassword", CurrentPassword: "wrongpassword",
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
}) })
@@ -100,7 +100,7 @@ func TestUserHandlers_Integration(t *testing.T) {
NewPassword: "newpassword123", NewPassword: "newpassword123",
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
// Verify can login with new password // Verify can login with new password
@@ -109,7 +109,7 @@ func TestUserHandlers_Integration(t *testing.T) {
Password: "newpassword123", Password: "newpassword123",
} }
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, "", nil) rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, nil)
assert.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, http.StatusOK, rr.Code)
currentPassword = updateReq.NewPassword currentPassword = updateReq.NewPassword
@@ -120,7 +120,7 @@ func TestUserHandlers_Integration(t *testing.T) {
NewPassword: "newpass123", NewPassword: "newpass123",
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
}) })
@@ -130,7 +130,7 @@ func TestUserHandlers_Integration(t *testing.T) {
NewPassword: "newpass123", NewPassword: "newpass123",
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
}) })
@@ -140,61 +140,40 @@ func TestUserHandlers_Integration(t *testing.T) {
NewPassword: "short", NewPassword: "short",
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
}) })
t.Run("duplicate email", func(t *testing.T) { t.Run("duplicate email", func(t *testing.T) {
updateReq := handlers.UpdateProfileRequest{ updateReq := handlers.UpdateProfileRequest{
Email: h.AdminUser.Email, Email: h.AdminTestUser.userModel.Email,
CurrentPassword: currentPassword, CurrentPassword: currentPassword,
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
assert.Equal(t, http.StatusConflict, rr.Code) assert.Equal(t, http.StatusConflict, rr.Code)
}) })
}) })
t.Run("delete account", func(t *testing.T) { t.Run("delete account", func(t *testing.T) {
// Create a new user that we can delete
createReq := handlers.CreateUserRequest{
Email: "todelete@test.com",
DisplayName: "To Delete",
Password: "password123",
Role: models.RoleEditor,
}
rr := h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.AdminToken, nil) deleteUserPassword := "password123"
require.Equal(t, http.StatusOK, rr.Code) testDeleteUser := h.createTestUser(t, "todelete@test.com", deleteUserPassword, models.RoleEditor)
var newUser models.User
err := json.NewDecoder(rr.Body).Decode(&newUser)
require.NoError(t, err)
// Get token for new user
loginReq := handlers.LoginRequest{
Email: createReq.Email,
Password: createReq.Password,
}
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, "", nil)
require.Equal(t, http.StatusOK, rr.Code)
var loginResp handlers.LoginResponse
err = json.NewDecoder(rr.Body).Decode(&loginResp)
require.NoError(t, err)
userToken := loginResp.AccessToken
t.Run("successful delete", func(t *testing.T) { t.Run("successful delete", func(t *testing.T) {
deleteReq := handlers.DeleteAccountRequest{ deleteReq := handlers.DeleteAccountRequest{
Password: createReq.Password, Password: deleteUserPassword,
} }
rr := h.makeRequest(t, http.MethodDelete, "/api/v1/profile", deleteReq, userToken, nil) rr := h.makeRequest(t, http.MethodDelete, "/api/v1/profile", deleteReq, testDeleteUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusNoContent, rr.Code)
// Verify user is deleted // Verify user is deleted
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, "", nil) loginReq := handlers.LoginRequest{
Email: testDeleteUser.userModel.Email,
Password: deleteUserPassword,
}
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, testDeleteUser)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
}) })
@@ -203,7 +182,7 @@ func TestUserHandlers_Integration(t *testing.T) {
Password: "wrongpassword", Password: "wrongpassword",
} }
rr := h.makeRequest(t, http.MethodDelete, "/api/v1/profile", deleteReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodDelete, "/api/v1/profile", deleteReq, h.RegularTestUser)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
}) })
@@ -212,7 +191,7 @@ func TestUserHandlers_Integration(t *testing.T) {
Password: "admin123", // Admin password from test harness Password: "admin123", // Admin password from test harness
} }
rr := h.makeRequest(t, http.MethodDelete, "/api/v1/profile", deleteReq, h.AdminToken, nil) rr := h.makeRequest(t, http.MethodDelete, "/api/v1/profile", deleteReq, h.AdminTestUser)
assert.Equal(t, http.StatusForbidden, rr.Code) assert.Equal(t, http.StatusForbidden, rr.Code)
}) })
}) })

View File

@@ -5,21 +5,53 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"novamd/internal/context" "lemma/internal/context"
"novamd/internal/models" "lemma/internal/logging"
"lemma/internal/models"
) )
// ListWorkspaces returns a list of all workspaces for the current user // DeleteWorkspaceResponse contains the name of the next workspace after deleting the current one
type DeleteWorkspaceResponse struct {
NextWorkspaceName string `json:"nextWorkspaceName"`
}
// LastWorkspaceNameResponse contains the name of the last opened workspace
type LastWorkspaceNameResponse struct {
LastWorkspaceName string `json:"lastWorkspaceName"`
}
func getWorkspaceLogger() logging.Logger {
return getHandlersLogger().WithGroup("workspace")
}
// ListWorkspaces godoc
// @Summary List workspaces
// @Description Lists all workspaces for the current user
// @Tags workspaces
// @ID listWorkspaces
// @Security CookieAuth
// @Produce json
// @Success 200 {array} models.Workspace
// @Failure 500 {object} ErrorResponse "Failed to list workspaces"
// @Router /workspaces [get]
func (h *Handler) ListWorkspaces() http.HandlerFunc { func (h *Handler) ListWorkspaces() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getWorkspaceLogger().With(
"handler", "ListWorkspaces",
"userID", ctx.UserID,
"clientIP", r.RemoteAddr,
)
workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID) workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID)
if err != nil { if err != nil {
http.Error(w, "Failed to list workspaces", http.StatusInternalServerError) log.Error("failed to fetch workspaces from database",
"error", err.Error(),
)
respondError(w, "Failed to list workspaces", http.StatusInternalServerError)
return return
} }
@@ -27,33 +59,67 @@ func (h *Handler) ListWorkspaces() http.HandlerFunc {
} }
} }
// CreateWorkspace creates a new workspace // CreateWorkspace godoc
// @Summary Create workspace
// @Description Creates a new workspace
// @Tags workspaces
// @ID createWorkspace
// @Security CookieAuth
// @Accept json
// @Produce json
// @Param body body models.Workspace true "Workspace"
// @Success 200 {object} models.Workspace
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 400 {object} ErrorResponse "Invalid workspace"
// @Failure 500 {object} ErrorResponse "Failed to create workspace"
// @Failure 500 {object} ErrorResponse "Failed to initialize workspace directory"
// @Failure 500 {object} ErrorResponse "Failed to setup git repo"
// @Router /workspaces [post]
func (h *Handler) CreateWorkspace() http.HandlerFunc { func (h *Handler) CreateWorkspace() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getWorkspaceLogger().With(
"handler", "CreateWorkspace",
"userID", ctx.UserID,
"clientIP", r.RemoteAddr,
)
var workspace models.Workspace var workspace models.Workspace
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil { if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest) log.Debug("invalid request body received",
"error", err.Error(),
)
respondError(w, "Invalid request body", http.StatusBadRequest)
return return
} }
if err := workspace.ValidateGitSettings(); err != nil { if err := workspace.ValidateGitSettings(); err != nil {
http.Error(w, "Invalid workspace", http.StatusBadRequest) log.Debug("invalid git settings provided",
"error", err.Error(),
)
respondError(w, "Invalid workspace", http.StatusBadRequest)
return return
} }
workspace.UserID = ctx.UserID workspace.UserID = ctx.UserID
if err := h.DB.CreateWorkspace(&workspace); err != nil { if err := h.DB.CreateWorkspace(&workspace); err != nil {
http.Error(w, "Failed to create workspace", http.StatusInternalServerError) log.Error("failed to create workspace in database",
"error", err.Error(),
"workspaceName", workspace.Name,
)
respondError(w, "Failed to create workspace", http.StatusInternalServerError)
return return
} }
if err := h.Storage.InitializeUserWorkspace(workspace.UserID, workspace.ID); err != nil { if err := h.Storage.InitializeUserWorkspace(workspace.UserID, workspace.ID); err != nil {
http.Error(w, "Failed to initialize workspace directory", http.StatusInternalServerError) log.Error("failed to initialize workspace directory",
"error", err.Error(),
"workspaceID", workspace.ID,
)
respondError(w, "Failed to initialize workspace directory", http.StatusInternalServerError)
return return
} }
@@ -67,16 +133,35 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc {
workspace.GitCommitName, workspace.GitCommitName,
workspace.GitCommitEmail, workspace.GitCommitEmail,
); err != nil { ); err != nil {
http.Error(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError) log.Error("failed to setup git repository",
"error", err.Error(),
"workspaceID", workspace.ID,
)
respondError(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError)
return return
} }
} }
log.Info("workspace created",
"workspaceID", workspace.ID,
"workspaceName", workspace.Name,
"gitEnabled", workspace.GitEnabled,
)
respondJSON(w, workspace) respondJSON(w, workspace)
} }
} }
// GetWorkspace returns the current workspace // GetWorkspace godoc
// @Summary Get workspace
// @Description Returns the current workspace
// @Tags workspaces
// @ID getWorkspace
// @Security CookieAuth
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Success 200 {object} models.Workspace
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /workspaces/{workspace_name} [get]
func (h *Handler) GetWorkspace() http.HandlerFunc { func (h *Handler) GetWorkspace() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
@@ -106,17 +191,40 @@ func gitSettingsChanged(new, old *models.Workspace) bool {
return false return false
} }
// UpdateWorkspace updates the current workspace // UpdateWorkspace godoc
// @Summary Update workspace
// @Description Updates the current workspace
// @Tags workspaces
// @ID updateWorkspace
// @Security CookieAuth
// @Accept json
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Param body body models.Workspace true "Workspace"
// @Success 200 {object} models.Workspace
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 500 {object} ErrorResponse "Failed to update workspace"
// @Failure 500 {object} ErrorResponse "Failed to setup git repo"
// @Router /workspaces/{workspace_name} [put]
func (h *Handler) UpdateWorkspace() http.HandlerFunc { func (h *Handler) UpdateWorkspace() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getWorkspaceLogger().With(
"handler", "UpdateWorkspace",
"userID", ctx.UserID,
"workspaceID", ctx.Workspace.ID,
"clientIP", r.RemoteAddr,
)
var workspace models.Workspace var workspace models.Workspace
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil { if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest) log.Debug("invalid request body received",
"error", err.Error(),
)
respondError(w, "Invalid request body", http.StatusBadRequest)
return return
} }
@@ -126,12 +234,23 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc {
// Validate the workspace // Validate the workspace
if err := workspace.Validate(); err != nil { if err := workspace.Validate(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) log.Debug("invalid workspace configuration",
"error", err.Error(),
)
respondError(w, err.Error(), http.StatusBadRequest)
return return
} }
// Track what's changed for logging
changes := map[string]bool{
"gitSettings": gitSettingsChanged(&workspace, ctx.Workspace),
"name": workspace.Name != ctx.Workspace.Name,
"theme": workspace.Theme != ctx.Workspace.Theme,
"autoSave": workspace.AutoSave != ctx.Workspace.AutoSave,
}
// Handle Git repository setup/teardown if Git settings changed // Handle Git repository setup/teardown if Git settings changed
if gitSettingsChanged(&workspace, ctx.Workspace) { if changes["gitSettings"] {
if workspace.GitEnabled { if workspace.GitEnabled {
if err := h.Storage.SetupGitRepo( if err := h.Storage.SetupGitRepo(
ctx.UserID, ctx.UserID,
@@ -142,17 +261,22 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc {
workspace.GitCommitName, workspace.GitCommitName,
workspace.GitCommitEmail, workspace.GitCommitEmail,
); err != nil { ); err != nil {
http.Error(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError) log.Error("failed to setup git repository",
"error", err.Error(),
)
respondError(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError)
return return
} }
} else { } else {
h.Storage.DisableGitRepo(ctx.UserID, ctx.Workspace.ID) h.Storage.DisableGitRepo(ctx.UserID, ctx.Workspace.ID)
} }
} }
if err := h.DB.UpdateWorkspace(&workspace); err != nil { if err := h.DB.UpdateWorkspace(&workspace); err != nil {
http.Error(w, "Failed to update workspace", http.StatusInternalServerError) log.Error("failed to update workspace in database",
"error", err.Error(),
)
respondError(w, "Failed to update workspace", http.StatusInternalServerError)
return return
} }
@@ -160,23 +284,49 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc {
} }
} }
// DeleteWorkspace deletes the current workspace // DeleteWorkspace godoc
// @Summary Delete workspace
// @Description Deletes the current workspace
// @Tags workspaces
// @ID deleteWorkspace
// @Security CookieAuth
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @Success 200 {object} DeleteWorkspaceResponse
// @Failure 400 {object} ErrorResponse "Cannot delete the last workspace"
// @Failure 500 {object} ErrorResponse "Failed to get workspaces"
// @Failure 500 {object} ErrorResponse "Failed to start transaction"
// @Failure 500 {object} ErrorResponse "Failed to update last workspace"
// @Failure 500 {object} ErrorResponse "Failed to delete workspace"
// @Failure 500 {object} ErrorResponse "Failed to rollback transaction"
// @Failure 500 {object} ErrorResponse "Failed to commit transaction"
// @Router /workspaces/{workspace_name} [delete]
func (h *Handler) DeleteWorkspace() http.HandlerFunc { func (h *Handler) DeleteWorkspace() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getWorkspaceLogger().With(
"handler", "DeleteWorkspace",
"userID", ctx.UserID,
"workspaceID", ctx.Workspace.ID,
"clientIP", r.RemoteAddr,
)
// Check if this is the user's last workspace // Check if this is the user's last workspace
workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID) workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID)
if err != nil { if err != nil {
http.Error(w, "Failed to get workspaces", http.StatusInternalServerError) log.Error("failed to fetch workspaces from database",
"error", err.Error(),
)
respondError(w, "Failed to get workspaces", http.StatusInternalServerError)
return return
} }
if len(workspaces) <= 1 { if len(workspaces) <= 1 {
http.Error(w, "Cannot delete the last workspace", http.StatusBadRequest) log.Debug("attempted to delete last workspace")
respondError(w, "Cannot delete the last workspace", http.StatusBadRequest)
return return
} }
@@ -191,83 +341,143 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc {
} }
} }
// Start transaction
tx, err := h.DB.Begin() tx, err := h.DB.Begin()
if err != nil { if err != nil {
http.Error(w, "Failed to start transaction", http.StatusInternalServerError) log.Error("failed to start database transaction",
"error", err.Error(),
)
respondError(w, "Failed to start transaction", http.StatusInternalServerError)
return return
} }
defer func() { defer func() {
if err := tx.Rollback(); err != nil && err != sql.ErrTxDone { if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
http.Error(w, "Failed to rollback transaction", http.StatusInternalServerError) log.Error("failed to rollback transaction",
"error", err.Error(),
)
respondError(w, "Failed to rollback transaction", http.StatusInternalServerError)
} }
}() }()
// Update last workspace ID first // Update last workspace ID first
err = h.DB.UpdateLastWorkspaceTx(tx, ctx.UserID, nextWorkspaceID) err = h.DB.UpdateLastWorkspaceTx(tx, ctx.UserID, nextWorkspaceID)
if err != nil { if err != nil {
http.Error(w, "Failed to update last workspace", http.StatusInternalServerError) log.Error("failed to update last workspace reference",
"error", err.Error(),
"nextWorkspaceID", nextWorkspaceID,
)
respondError(w, "Failed to update last workspace", http.StatusInternalServerError)
return return
} }
// Delete the workspace // Delete the workspace
err = h.DB.DeleteWorkspaceTx(tx, ctx.Workspace.ID) err = h.DB.DeleteWorkspaceTx(tx, ctx.Workspace.ID)
if err != nil { if err != nil {
http.Error(w, "Failed to delete workspace", http.StatusInternalServerError) log.Error("failed to delete workspace from database",
"error", err.Error(),
)
respondError(w, "Failed to delete workspace", http.StatusInternalServerError)
return return
} }
// Commit transaction // Commit transaction
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
http.Error(w, "Failed to commit transaction", http.StatusInternalServerError) log.Error("failed to commit transaction",
"error", err.Error(),
)
respondError(w, "Failed to commit transaction", http.StatusInternalServerError)
return return
} }
log.Info("workspace deleted",
"workspaceName", ctx.Workspace.Name,
"nextWorkspaceName", nextWorkspaceName,
)
// Return the next workspace ID in the response so frontend knows where to redirect // Return the next workspace ID in the response so frontend knows where to redirect
respondJSON(w, map[string]string{"nextWorkspaceName": nextWorkspaceName}) respondJSON(w, &DeleteWorkspaceResponse{NextWorkspaceName: nextWorkspaceName})
} }
} }
// GetLastWorkspaceName returns the name of the last opened workspace // GetLastWorkspaceName godoc
// @Summary Get last workspace name
// @Description Returns the name of the last opened workspace
// @Tags workspaces
// @ID getLastWorkspaceName
// @Security CookieAuth
// @Produce json
// @Success 200 {object} LastWorkspaceNameResponse
// @Failure 500 {object} ErrorResponse "Failed to get last workspace"
// @Router /workspaces/last [get]
func (h *Handler) GetLastWorkspaceName() http.HandlerFunc { func (h *Handler) GetLastWorkspaceName() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getWorkspaceLogger().With(
"handler", "GetLastWorkspaceName",
"userID", ctx.UserID,
"clientIP", r.RemoteAddr,
)
workspaceName, err := h.DB.GetLastWorkspaceName(ctx.UserID) workspaceName, err := h.DB.GetLastWorkspaceName(ctx.UserID)
if err != nil { if err != nil {
http.Error(w, "Failed to get last workspace", http.StatusInternalServerError) log.Error("failed to fetch last workspace name",
"error", err.Error(),
)
respondError(w, "Failed to get last workspace", http.StatusInternalServerError)
return return
} }
respondJSON(w, map[string]string{"lastWorkspaceName": workspaceName}) respondJSON(w, &LastWorkspaceNameResponse{LastWorkspaceName: workspaceName})
} }
} }
// UpdateLastWorkspaceName updates the name of the last opened workspace // UpdateLastWorkspaceName godoc
// @Summary Update last workspace name
// @Description Updates the name of the last opened workspace
// @Tags workspaces
// @ID updateLastWorkspaceName
// @Security CookieAuth
// @Accept json
// @Produce json
// @Success 204 "No Content - Last workspace updated successfully"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 500 {object} ErrorResponse "Failed to update last workspace"
// @Router /workspaces/last [put]
func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc { func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r) ctx, ok := context.GetRequestContext(w, r)
if !ok { if !ok {
return return
} }
log := getWorkspaceLogger().With(
"handler", "UpdateLastWorkspaceName",
"userID", ctx.UserID,
"clientIP", r.RemoteAddr,
)
var requestBody struct { var requestBody struct {
WorkspaceName string `json:"workspaceName"` WorkspaceName string `json:"workspaceName"`
} }
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest) log.Debug("invalid request body received",
"error", err.Error(),
)
respondError(w, "Invalid request body", http.StatusBadRequest)
return return
} }
if err := h.DB.UpdateLastWorkspace(ctx.UserID, requestBody.WorkspaceName); err != nil { if err := h.DB.UpdateLastWorkspace(ctx.UserID, requestBody.WorkspaceName); err != nil {
http.Error(w, "Failed to update last workspace", http.StatusInternalServerError) log.Error("failed to update last workspace",
"error", err.Error(),
"workspaceName", requestBody.WorkspaceName,
)
respondError(w, "Failed to update last workspace", http.StatusInternalServerError)
return return
} }
respondJSON(w, map[string]string{"message": "Last workspace updated successfully"}) w.WriteHeader(http.StatusNoContent)
} }
} }

View File

@@ -8,7 +8,7 @@ import (
"net/url" "net/url"
"testing" "testing"
"novamd/internal/models" "lemma/internal/models"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -20,7 +20,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
t.Run("list workspaces", func(t *testing.T) { t.Run("list workspaces", func(t *testing.T) {
t.Run("successful list", func(t *testing.T) { t.Run("successful list", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces", nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var workspaces []*models.Workspace var workspaces []*models.Workspace
@@ -30,7 +30,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
}) })
t.Run("unauthorized", func(t *testing.T) { t.Run("unauthorized", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces", nil, "", nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces", nil, nil)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
}) })
}) })
@@ -41,14 +41,14 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
Name: "Test Workspace", Name: "Test Workspace",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var created models.Workspace var created models.Workspace
err := json.NewDecoder(rr.Body).Decode(&created) err := json.NewDecoder(rr.Body).Decode(&created)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, workspace.Name, created.Name) assert.Equal(t, workspace.Name, created.Name)
assert.Equal(t, h.RegularUser.ID, created.UserID) assert.Equal(t, h.RegularTestUser.session.UserID, created.UserID)
assert.NotZero(t, created.ID) assert.NotZero(t, created.ID)
}) })
@@ -64,7 +64,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
GitCommitEmail: "test@example.com", GitCommitEmail: "test@example.com",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var created models.Workspace var created models.Workspace
@@ -86,7 +86,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
// Missing required Git settings // Missing required Git settings
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
}) })
}) })
@@ -95,7 +95,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
workspace := &models.Workspace{ workspace := &models.Workspace{
Name: "Test Workspace Operations", Name: "Test Workspace Operations",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
err := json.NewDecoder(rr.Body).Decode(workspace) err := json.NewDecoder(rr.Body).Decode(workspace)
require.NoError(t, err) require.NoError(t, err)
@@ -105,7 +105,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
t.Run("get workspace", func(t *testing.T) { t.Run("get workspace", func(t *testing.T) {
t.Run("successful get", func(t *testing.T) { t.Run("successful get", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, baseURL, nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, baseURL, nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var got models.Workspace var got models.Workspace
@@ -116,13 +116,13 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
}) })
t.Run("nonexistent workspace", func(t *testing.T) { t.Run("nonexistent workspace", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/nonexistent", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/nonexistent", nil, h.RegularTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
t.Run("unauthorized access", func(t *testing.T) { t.Run("unauthorized access", func(t *testing.T) {
// Try accessing with another user's token // Try accessing with another user's token
rr := h.makeRequest(t, http.MethodGet, baseURL, nil, h.AdminToken, nil) rr := h.makeRequest(t, http.MethodGet, baseURL, nil, h.AdminTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
}) })
@@ -131,7 +131,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
t.Run("update name", func(t *testing.T) { t.Run("update name", func(t *testing.T) {
workspace.Name = "Updated Workspace" workspace.Name = "Updated Workspace"
rr := h.makeRequest(t, http.MethodPut, baseURL, workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, baseURL, workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var updated models.Workspace var updated models.Workspace
@@ -152,7 +152,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
ShowHiddenFiles: true, ShowHiddenFiles: true,
} }
rr := h.makeRequest(t, http.MethodPut, baseURL, update, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, baseURL, update, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var updated models.Workspace var updated models.Workspace
@@ -176,7 +176,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
GitCommitEmail: "test@example.com", GitCommitEmail: "test@example.com",
} }
rr := h.makeRequest(t, http.MethodPut, baseURL, update, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, baseURL, update, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var updated models.Workspace var updated models.Workspace
@@ -200,14 +200,14 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
// Missing required Git settings // Missing required Git settings
} }
rr := h.makeRequest(t, http.MethodPut, baseURL, update, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, baseURL, update, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
}) })
}) })
t.Run("last workspace", func(t *testing.T) { t.Run("last workspace", func(t *testing.T) {
t.Run("get last workspace", func(t *testing.T) { t.Run("get last workspace", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/last", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/last", nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var response struct { var response struct {
@@ -225,11 +225,11 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
WorkspaceName: workspace.Name, WorkspaceName: workspace.Name,
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/workspaces/last", req, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/workspaces/last", req, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusNoContent, rr.Code)
// Verify the update // Verify the update
rr = h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/last", nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/last", nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var response struct { var response struct {
@@ -243,7 +243,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
t.Run("delete workspace", func(t *testing.T) { t.Run("delete workspace", func(t *testing.T) {
// Get current workspaces to know how many we have // Get current workspaces to know how many we have
rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces", nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var existingWorkspaces []*models.Workspace var existingWorkspaces []*models.Workspace
@@ -254,13 +254,13 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
newWorkspace := &models.Workspace{ newWorkspace := &models.Workspace{
Name: "Workspace To Delete", Name: "Workspace To Delete",
} }
rr = h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", newWorkspace, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", newWorkspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
err = json.NewDecoder(rr.Body).Decode(newWorkspace) err = json.NewDecoder(rr.Body).Decode(newWorkspace)
require.NoError(t, err) require.NoError(t, err)
t.Run("successful delete", func(t *testing.T) { t.Run("successful delete", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodDelete, "/api/v1/workspaces/"+url.PathEscape(newWorkspace.Name), nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodDelete, "/api/v1/workspaces/"+url.PathEscape(newWorkspace.Name), nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var response struct { var response struct {
@@ -271,7 +271,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
assert.NotEmpty(t, response.NextWorkspaceName) assert.NotEmpty(t, response.NextWorkspaceName)
// Verify workspace is deleted // Verify workspace is deleted
rr = h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/"+url.PathEscape(newWorkspace.Name), nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/"+url.PathEscape(newWorkspace.Name), nil, h.RegularTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
@@ -279,13 +279,13 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
// Delete all but one workspace // Delete all but one workspace
for i := 0; i < len(existingWorkspaces)-1; i++ { for i := 0; i < len(existingWorkspaces)-1; i++ {
ws := existingWorkspaces[i] ws := existingWorkspaces[i]
rr := h.makeRequest(t, http.MethodDelete, "/api/v1/workspaces/"+url.PathEscape(ws.Name), nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodDelete, "/api/v1/workspaces/"+url.PathEscape(ws.Name), nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
} }
// Try to delete the last remaining workspace // Try to delete the last remaining workspace
lastWs := existingWorkspaces[len(existingWorkspaces)-1] lastWs := existingWorkspaces[len(existingWorkspaces)-1]
rr := h.makeRequest(t, http.MethodDelete, "/api/v1/workspaces/"+url.PathEscape(lastWs.Name), nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodDelete, "/api/v1/workspaces/"+url.PathEscape(lastWs.Name), nil, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
}) })
@@ -294,11 +294,11 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
workspace := &models.Workspace{ workspace := &models.Workspace{
Name: "Unauthorized Delete Test", Name: "Unauthorized Delete Test",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
// Try to delete with wrong user's token // Try to delete with wrong user's token
rr = h.makeRequest(t, http.MethodDelete, "/api/v1/workspaces/"+url.PathEscape(workspace.Name), nil, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodDelete, "/api/v1/workspaces/"+url.PathEscape(workspace.Name), nil, h.AdminTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
}) })

View File

@@ -0,0 +1,116 @@
// Package logging provides a simple logging interface for the server.
package logging
import (
"log/slog"
"os"
)
// Logger represents the interface for logging operations
type Logger interface {
Debug(msg string, args ...any)
Info(msg string, args ...any)
Warn(msg string, args ...any)
Error(msg string, args ...any)
WithGroup(name string) Logger
With(args ...any) Logger
}
// Implementation of the Logger interface using slog
type logger struct {
logger *slog.Logger
}
// Logger is the global logger instance
var defaultLogger Logger
// LogLevel represents the log level
type LogLevel slog.Level
// Log levels
const (
DEBUG LogLevel = LogLevel(slog.LevelDebug)
INFO LogLevel = LogLevel(slog.LevelInfo)
WARN LogLevel = LogLevel(slog.LevelWarn)
ERROR LogLevel = LogLevel(slog.LevelError)
)
// Setup initializes the logger with the given minimum log level
func Setup(minLevel LogLevel) {
opts := &slog.HandlerOptions{
Level: slog.Level(minLevel),
}
defaultLogger = &logger{
logger: slog.New(slog.NewTextHandler(os.Stdout, opts)),
}
}
// ParseLogLevel converts a string to a LogLevel
func ParseLogLevel(level string) LogLevel {
switch level {
case "debug":
return DEBUG
case "warn":
return WARN
case "error":
return ERROR
default:
return INFO
}
}
// Implementation of Logger interface methods
func (l *logger) Debug(msg string, args ...any) {
l.logger.Debug(msg, args...)
}
func (l *logger) Info(msg string, args ...any) {
l.logger.Info(msg, args...)
}
func (l *logger) Warn(msg string, args ...any) {
l.logger.Warn(msg, args...)
}
func (l *logger) Error(msg string, args ...any) {
l.logger.Error(msg, args...)
}
func (l *logger) WithGroup(name string) Logger {
return &logger{logger: l.logger.WithGroup(name)}
}
func (l *logger) With(args ...any) Logger {
return &logger{logger: l.logger.With(args...)}
}
// Debug logs a debug message
func Debug(msg string, args ...any) {
defaultLogger.Debug(msg, args...)
}
// Info logs an info message
func Info(msg string, args ...any) {
defaultLogger.Info(msg, args...)
}
// Warn logs a warning message
func Warn(msg string, args ...any) {
defaultLogger.Warn(msg, args...)
}
// Error logs an error message
func Error(msg string, args ...any) {
defaultLogger.Error(msg, args...)
}
// WithGroup adds a group to the logger context
func WithGroup(name string) Logger {
return defaultLogger.WithGroup(name)
}
// With adds key-value pairs to the logger context
func With(args ...any) Logger {
return defaultLogger.With(args...)
}

View File

@@ -8,6 +8,8 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io" "io"
"lemma/internal/logging"
) )
// Service is an interface for encrypting and decrypting strings // Service is an interface for encrypting and decrypting strings
@@ -20,6 +22,15 @@ type encryptor struct {
gcm cipher.AEAD gcm cipher.AEAD
} }
var logger logging.Logger
func getLogger() logging.Logger {
if logger == nil {
logger = logging.WithGroup("secrets")
}
return logger
}
// ValidateKey checks if the provided base64-encoded key is suitable for AES-256 // ValidateKey checks if the provided base64-encoded key is suitable for AES-256
func ValidateKey(key string) error { func ValidateKey(key string) error {
_, err := decodeAndValidateKey(key) _, err := decodeAndValidateKey(key)
@@ -73,7 +84,10 @@ func NewService(key string) (Service, error) {
// Encrypt encrypts the plaintext using AES-256-GCM // Encrypt encrypts the plaintext using AES-256-GCM
func (e *encryptor) Encrypt(plaintext string) (string, error) { func (e *encryptor) Encrypt(plaintext string) (string, error) {
log := getLogger()
if plaintext == "" { if plaintext == "" {
log.Debug("empty plaintext provided, returning empty string")
return "", nil return "", nil
} }
@@ -83,12 +97,18 @@ func (e *encryptor) Encrypt(plaintext string) (string, error) {
} }
ciphertext := e.gcm.Seal(nonce, nonce, []byte(plaintext), nil) ciphertext := e.gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil encoded := base64.StdEncoding.EncodeToString(ciphertext)
log.Debug("data encrypted", "inputLength", len(plaintext), "outputLength", len(encoded))
return encoded, nil
} }
// Decrypt decrypts the ciphertext using AES-256-GCM // Decrypt decrypts the ciphertext using AES-256-GCM
func (e *encryptor) Decrypt(ciphertext string) (string, error) { func (e *encryptor) Decrypt(ciphertext string) (string, error) {
log := getLogger()
if ciphertext == "" { if ciphertext == "" {
log.Debug("empty ciphertext provided, returning empty string")
return "", nil return "", nil
} }
@@ -108,5 +128,6 @@ func (e *encryptor) Decrypt(ciphertext string) (string, error) {
return "", err return "", err
} }
log.Debug("data decrypted", "inputLength", len(ciphertext), "outputLength", len(plaintext))
return string(plaintext), nil return string(plaintext), nil
} }

View File

@@ -5,7 +5,8 @@ import (
"strings" "strings"
"testing" "testing"
"novamd/internal/secrets" "lemma/internal/secrets"
_ "lemma/internal/testenv"
) )
func TestValidateKey(t *testing.T) { func TestValidateKey(t *testing.T) {

View File

@@ -1,5 +1,4 @@
// storage/errors.go // Package storage provides functionalities to interact with the storage system (filesystem).
package storage package storage
import ( import (

View File

@@ -1,5 +1,3 @@
// Package storage provides functionalities to interact with the file system,
// including listing files, finding files by name, getting file content, saving files, and deleting files.
package storage package storage
import ( import (
@@ -33,7 +31,12 @@ type FileNode struct {
// Workspace is identified by the given userID and workspaceID. // Workspace is identified by the given userID and workspaceID.
func (s *Service) ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) { func (s *Service) ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) {
workspacePath := s.GetWorkspacePath(userID, workspaceID) workspacePath := s.GetWorkspacePath(userID, workspaceID)
return s.walkDirectory(workspacePath, "") nodes, err := s.walkDirectory(workspacePath, "")
if err != nil {
return nil, err
}
return nodes, nil
} }
// walkDirectory recursively walks the directory and returns a list of files and directories. // walkDirectory recursively walks the directory and returns a list of files and directories.
@@ -147,6 +150,8 @@ func (s *Service) GetFileContent(userID, workspaceID int, filePath string) ([]by
// SaveFile writes the content to the file at the given filePath. // SaveFile writes the content to the file at the given filePath.
// Path must be a relative path within the workspace directory given by userID and workspaceID. // Path must be a relative path within the workspace directory given by userID and workspaceID.
func (s *Service) SaveFile(userID, workspaceID int, filePath string, content []byte) error { func (s *Service) SaveFile(userID, workspaceID int, filePath string, content []byte) error {
log := getLogger()
fullPath, err := s.ValidatePath(userID, workspaceID, filePath) fullPath, err := s.ValidatePath(userID, workspaceID, filePath)
if err != nil { if err != nil {
return err return err
@@ -157,17 +162,36 @@ func (s *Service) SaveFile(userID, workspaceID int, filePath string, content []b
return err return err
} }
return s.fs.WriteFile(fullPath, content, 0644) if err := s.fs.WriteFile(fullPath, content, 0644); err != nil {
return err
}
log.Debug("file saved",
"userID", userID,
"workspaceID", workspaceID,
"path", filePath,
"size", len(content))
return nil
} }
// DeleteFile deletes the file at the given filePath. // DeleteFile deletes the file at the given filePath.
// Path must be a relative path within the workspace directory given by userID and workspaceID. // Path must be a relative path within the workspace directory given by userID and workspaceID.
func (s *Service) DeleteFile(userID, workspaceID int, filePath string) error { func (s *Service) DeleteFile(userID, workspaceID int, filePath string) error {
log := getLogger()
fullPath, err := s.ValidatePath(userID, workspaceID, filePath) fullPath, err := s.ValidatePath(userID, workspaceID, filePath)
if err != nil { if err != nil {
return err return err
} }
return s.fs.Remove(fullPath)
if err := s.fs.Remove(fullPath); err != nil {
return err
}
log.Debug("file deleted",
"userID", userID,
"workspaceID", workspaceID,
"path", filePath)
return nil
} }
// FileCountStats holds statistics about files in a workspace // FileCountStats holds statistics about files in a workspace
@@ -186,13 +210,22 @@ func (s *Service) GetFileStats(userID, workspaceID int) (*FileCountStats, error)
return nil, fmt.Errorf("workspace directory does not exist") return nil, fmt.Errorf("workspace directory does not exist")
} }
return s.countFilesInPath(workspacePath) stats, err := s.countFilesInPath(workspacePath)
if err != nil {
return nil, err
}
return stats, nil
} }
// GetTotalFileStats returns the total file statistics for the storage. // GetTotalFileStats returns the total file statistics for the storage.
func (s *Service) GetTotalFileStats() (*FileCountStats, error) { func (s *Service) GetTotalFileStats() (*FileCountStats, error) {
return s.countFilesInPath(s.RootDir) stats, err := s.countFilesInPath(s.RootDir)
if err != nil {
return nil, err
}
return stats, nil
} }
// countFilesInPath counts the total number of files and the total size of files in the given directory. // countFilesInPath counts the total number of files and the total size of files in the given directory.

View File

@@ -2,9 +2,11 @@ package storage_test
import ( import (
"io/fs" "io/fs"
"novamd/internal/storage" "lemma/internal/storage"
"path/filepath" "path/filepath"
"testing" "testing"
_ "lemma/internal/testenv"
) )
// TestFileNode ensures FileNode structs are created correctly // TestFileNode ensures FileNode structs are created correctly

View File

@@ -2,6 +2,7 @@ package storage
import ( import (
"io/fs" "io/fs"
"lemma/internal/logging"
"os" "os"
) )
@@ -17,6 +18,15 @@ type fileSystem interface {
IsNotExist(err error) bool IsNotExist(err error) bool
} }
var logger logging.Logger
func getLogger() logging.Logger {
if logger == nil {
logger = logging.WithGroup("storage")
}
return logger
}
// osFS implements the FileSystem interface using the real filesystem. // osFS implements the FileSystem interface using the real filesystem.
type osFS struct{} type osFS struct{}

View File

@@ -5,6 +5,8 @@ import (
"io/fs" "io/fs"
"path/filepath" "path/filepath"
"time" "time"
_ "lemma/internal/testenv"
) )
type mockDirEntry struct { type mockDirEntry struct {

View File

@@ -2,14 +2,14 @@ package storage
import ( import (
"fmt" "fmt"
"novamd/internal/git" "lemma/internal/git"
) )
// RepositoryManager defines the interface for managing Git repositories. // RepositoryManager defines the interface for managing Git repositories.
type RepositoryManager interface { type RepositoryManager interface {
SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken, commitName, commitEmail string) error SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken, commitName, commitEmail string) error
DisableGitRepo(userID, workspaceID int) DisableGitRepo(userID, workspaceID int)
StageCommitAndPush(userID, workspaceID int, message string) error StageCommitAndPush(userID, workspaceID int, message string) (git.CommitHash, error)
Pull(userID, workspaceID int) error Pull(userID, workspaceID int) error
} }
@@ -17,15 +17,23 @@ type RepositoryManager interface {
// The repository is cloned from the given gitURL using the given gitUser and gitToken. // The repository is cloned from the given gitURL using the given gitUser and gitToken.
func (s *Service) SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken, commitName, commitEmail string) error { func (s *Service) SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken, commitName, commitEmail string) error {
workspacePath := s.GetWorkspacePath(userID, workspaceID) workspacePath := s.GetWorkspacePath(userID, workspaceID)
if _, ok := s.GitRepos[userID]; !ok { if _, ok := s.GitRepos[userID]; !ok {
s.GitRepos[userID] = make(map[int]git.Client) s.GitRepos[userID] = make(map[int]git.Client)
} }
s.GitRepos[userID][workspaceID] = s.newGitClient(gitURL, gitUser, gitToken, workspacePath, commitName, commitEmail) s.GitRepos[userID][workspaceID] = s.newGitClient(gitURL, gitUser, gitToken, workspacePath, commitName, commitEmail)
return s.GitRepos[userID][workspaceID].EnsureRepo() return s.GitRepos[userID][workspaceID].EnsureRepo()
} }
// DisableGitRepo disables the Git repository for the given userID and workspaceID. // DisableGitRepo disables the Git repository for the given userID and workspaceID.
func (s *Service) DisableGitRepo(userID, workspaceID int) { func (s *Service) DisableGitRepo(userID, workspaceID int) {
log := getLogger().WithGroup("git")
log.Debug("disabling git repository",
"userID", userID,
"workspaceID", workspaceID)
if userRepos, ok := s.GitRepos[userID]; ok { if userRepos, ok := s.GitRepos[userID]; ok {
delete(userRepos, workspaceID) delete(userRepos, workspaceID)
if len(userRepos) == 0 { if len(userRepos) == 0 {
@@ -36,17 +44,22 @@ func (s *Service) DisableGitRepo(userID, workspaceID int) {
// StageCommitAndPush stages, commit with the message, and pushes the changes to the Git repository. // StageCommitAndPush stages, commit with the message, and pushes the changes to the Git repository.
// The git repository belongs to the given userID and is associated with the given workspaceID. // The git repository belongs to the given userID and is associated with the given workspaceID.
func (s *Service) StageCommitAndPush(userID, workspaceID int, message string) error { func (s *Service) StageCommitAndPush(userID, workspaceID int, message string) (git.CommitHash, error) {
repo, ok := s.getGitRepo(userID, workspaceID) repo, ok := s.getGitRepo(userID, workspaceID)
if !ok { if !ok {
return fmt.Errorf("git settings not configured for this workspace") return git.CommitHash{}, fmt.Errorf("git settings not configured for this workspace")
} }
if err := repo.Commit(message); err != nil { hash, err := repo.Commit(message)
return err if err != nil {
return git.CommitHash{}, err
} }
return repo.Push() if err = repo.Push(); err != nil {
return hash, err
}
return hash, nil
} }
// Pull pulls the changes from the remote Git repository. // Pull pulls the changes from the remote Git repository.
@@ -57,7 +70,12 @@ func (s *Service) Pull(userID, workspaceID int) error {
return fmt.Errorf("git settings not configured for this workspace") return fmt.Errorf("git settings not configured for this workspace")
} }
return repo.Pull() err := repo.Pull()
if err != nil {
return err
}
return nil
} }
// getGitRepo returns the Git repository for the given user and workspace IDs. // getGitRepo returns the Git repository for the given user and workspace IDs.

View File

@@ -4,8 +4,9 @@ import (
"errors" "errors"
"testing" "testing"
"novamd/internal/git" "lemma/internal/git"
"novamd/internal/storage" "lemma/internal/storage"
_ "lemma/internal/testenv"
) )
// MockGitClient implements git.Client interface for testing // MockGitClient implements git.Client interface for testing
@@ -29,10 +30,10 @@ func (m *MockGitClient) Pull() error {
return m.ReturnError return m.ReturnError
} }
func (m *MockGitClient) Commit(message string) error { func (m *MockGitClient) Commit(message string) (git.CommitHash, error) {
m.CommitCalled = true m.CommitCalled = true
m.CommitMessage = message m.CommitMessage = message
return m.ReturnError return git.CommitHash{}, m.ReturnError
} }
func (m *MockGitClient) Push() error { func (m *MockGitClient) Push() error {
@@ -138,7 +139,7 @@ func TestGitOperations(t *testing.T) {
}) })
t.Run("operations on non-configured workspace", func(t *testing.T) { t.Run("operations on non-configured workspace", func(t *testing.T) {
err := s.StageCommitAndPush(1, 1, "test commit") _, err := s.StageCommitAndPush(1, 1, "test commit")
if err == nil { if err == nil {
t.Error("expected error for non-configured workspace, got nil") t.Error("expected error for non-configured workspace, got nil")
} }
@@ -157,7 +158,7 @@ func TestGitOperations(t *testing.T) {
s.GitRepos[1][1] = mockClient s.GitRepos[1][1] = mockClient
// Test commit and push // Test commit and push
err := s.StageCommitAndPush(1, 1, "test commit") _, err := s.StageCommitAndPush(1, 1, "test commit")
if err != nil { if err != nil {
t.Errorf("unexpected error: %v", err) t.Errorf("unexpected error: %v", err)
} }
@@ -189,7 +190,7 @@ func TestGitOperations(t *testing.T) {
s.GitRepos[1][1] = mockClient s.GitRepos[1][1] = mockClient
// Test commit error // Test commit error
err := s.StageCommitAndPush(1, 1, "test commit") _, err := s.StageCommitAndPush(1, 1, "test commit")
if err == nil { if err == nil {
t.Error("expected error for commit, got nil") t.Error("expected error for commit, got nil")
} }

View File

@@ -1,7 +1,7 @@
package storage package storage
import ( import (
"novamd/internal/git" "lemma/internal/git"
) )
// Manager interface combines all storage interfaces. // Manager interface combines all storage interfaces.

View File

@@ -43,6 +43,11 @@ func (s *Service) GetWorkspacePath(userID, workspaceID int) string {
// InitializeUserWorkspace creates the workspace directory for the given userID and workspaceID. // InitializeUserWorkspace creates the workspace directory for the given userID and workspaceID.
func (s *Service) InitializeUserWorkspace(userID, workspaceID int) error { func (s *Service) InitializeUserWorkspace(userID, workspaceID int) error {
log := getLogger()
log.Debug("initializing workspace directory",
"userID", userID,
"workspaceID", workspaceID)
workspacePath := s.GetWorkspacePath(userID, workspaceID) workspacePath := s.GetWorkspacePath(userID, workspaceID)
err := s.fs.MkdirAll(workspacePath, 0755) err := s.fs.MkdirAll(workspacePath, 0755)
if err != nil { if err != nil {
@@ -54,6 +59,11 @@ func (s *Service) InitializeUserWorkspace(userID, workspaceID int) error {
// DeleteUserWorkspace deletes the workspace directory for the given userID and workspaceID. // DeleteUserWorkspace deletes the workspace directory for the given userID and workspaceID.
func (s *Service) DeleteUserWorkspace(userID, workspaceID int) error { func (s *Service) DeleteUserWorkspace(userID, workspaceID int) error {
log := getLogger()
log.Debug("deleting workspace directory",
"userID", userID,
"workspaceID", workspaceID)
workspacePath := s.GetWorkspacePath(userID, workspaceID) workspacePath := s.GetWorkspacePath(userID, workspaceID)
err := s.fs.RemoveAll(workspacePath) err := s.fs.RemoveAll(workspacePath)
if err != nil { if err != nil {

View File

@@ -6,7 +6,8 @@ import (
"strings" "strings"
"testing" "testing"
"novamd/internal/storage" "lemma/internal/storage"
_ "lemma/internal/testenv"
) )
func TestValidatePath(t *testing.T) { func TestValidatePath(t *testing.T) {

View File

@@ -0,0 +1,9 @@
// Package testenv provides a setup for testing the application.
package testenv
import "lemma/internal/logging"
func init() {
// Initialize the logger
logging.Setup(logging.ERROR)
}