mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-11 00:50:47 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7de4f57fc3 | ||
|
|
9a49d87777 | ||
|
|
73362793ad | ||
|
|
d6bd471eb0 | ||
|
|
1a1df435de | ||
|
|
e031e67b3c | ||
|
|
402072540f | ||
|
|
be19a5efde | ||
|
|
aa04c76e1f | ||
|
|
1981764f80 | ||
|
|
83be259754 | ||
|
|
1995d19c3d | ||
|
|
2258bb1f70 | ||
|
|
4254b529ef | ||
|
|
a2971e3053 | ||
|
|
a7d912aa44 | ||
|
|
3dea48c691 | ||
|
|
ef4bee88b8 |
@@ -26,6 +26,24 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Needed to get all tags for versioning
|
||||
|
||||
# Set version information
|
||||
- name: Set Version
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.event.inputs.manual_version }}" != "" ]]; then
|
||||
echo "VERSION=${{ github.event.inputs.manual_version }}" >> $GITHUB_ENV
|
||||
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
else
|
||||
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")-$(git rev-parse --short HEAD)
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
fi
|
||||
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
|
||||
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
# Set up Docker Buildx for efficient builds
|
||||
- name: Set up Docker Buildx
|
||||
@@ -61,5 +79,11 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
BUILD_TIME=${{ env.BUILD_TIME }}
|
||||
COMMIT=${{ env.COMMIT }}
|
||||
UID=1000
|
||||
GID=1000
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -27,6 +27,24 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Needed to get all tags for versioning
|
||||
|
||||
# Set version information
|
||||
- name: Set Version
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.event.inputs.manual_version }}" != "" ]]; then
|
||||
echo "VERSION=${{ github.event.inputs.manual_version }}" >> $GITHUB_ENV
|
||||
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
else
|
||||
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")-$(git rev-parse --short HEAD)
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
fi
|
||||
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
|
||||
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
# Set up Docker Buildx for efficient builds
|
||||
- name: Set up Docker Buildx
|
||||
@@ -62,5 +80,11 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
BUILD_TIME=${{ env.BUILD_TIME }}
|
||||
COMMIT=${{ env.COMMIT }}
|
||||
UID=1000
|
||||
GID=1000
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -57,23 +57,29 @@ jobs:
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
# Also set build timestamp for versioning
|
||||
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
|
||||
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: Build for multiple platforms
|
||||
run: |
|
||||
mkdir -p dist
|
||||
|
||||
# Define common ldflags with version information
|
||||
LDFLAGS="-X github.com/starfleetcptn/gomft/components.AppVersion=$VERSION -X main.Version=$VERSION -X main.BuildTime=$BUILD_TIME -X main.Commit=$COMMIT -X github.com/starfleetcptn/gomft/components.BuildTime=$BUILD_TIME -X github.com/starfleetcptn/gomft/components.Commit=$COMMIT"
|
||||
|
||||
# Linux builds
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-amd64 .
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-arm64 .
|
||||
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-armv7 .
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-amd64 .
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-arm64 .
|
||||
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-armv7 .
|
||||
|
||||
# macOS builds
|
||||
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-darwin-amd64 .
|
||||
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-darwin-arm64 .
|
||||
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-darwin-amd64 .
|
||||
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-darwin-arm64 .
|
||||
|
||||
# Windows builds
|
||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-windows-amd64.exe .
|
||||
GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-windows-arm64.exe .
|
||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-windows-amd64.exe .
|
||||
GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-windows-arm64.exe .
|
||||
|
||||
# Create checksums
|
||||
cd dist
|
||||
@@ -95,4 +101,4 @@ jobs:
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
# The following line is not needed as we set permissions at workflow level
|
||||
# token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+34
-3
@@ -2,6 +2,11 @@ FROM golang:1.24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Accept build arguments for version information
|
||||
ARG VERSION=dev
|
||||
ARG BUILD_TIME=unknown
|
||||
ARG COMMIT=unknown
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git build-base
|
||||
|
||||
@@ -18,8 +23,10 @@ COPY . .
|
||||
# Generate template files from .templ files
|
||||
RUN templ generate
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o gomft
|
||||
# Compile the application with version information
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=${VERSION} -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT} -X github.com/starfleetcptn/gomft/components.BuildTime=${BUILD_TIME} -X github.com/starfleetcptn/gomft/components.Commit=${COMMIT}" \
|
||||
-o /app/gomft
|
||||
|
||||
# Install rclone
|
||||
RUN apk add --no-cache curl unzip && \
|
||||
@@ -34,10 +41,21 @@ RUN apk add --no-cache curl unzip && \
|
||||
# Create a smaller runtime image
|
||||
FROM alpine:3.19
|
||||
|
||||
# Add arguments for UID and GID with defaults
|
||||
ARG UID=1000
|
||||
ARG GID=1000
|
||||
ARG USERNAME=gomft
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite bash
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite bash shadow su-exec \
|
||||
&& apk add --no-cache --virtual .user-deps \
|
||||
shadow curl xz
|
||||
|
||||
# Create user and group with specified IDs
|
||||
RUN addgroup -g ${GID} ${USERNAME} && \
|
||||
adduser -D -u ${UID} -G ${USERNAME} -s /bin/sh ${USERNAME}
|
||||
|
||||
# Copy the binary from the builder stage
|
||||
COPY --from=builder /app/gomft /app/
|
||||
@@ -47,14 +65,27 @@ COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone
|
||||
COPY static/ /app/static/
|
||||
COPY components/ /app/components/
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Create data and backup directories
|
||||
RUN mkdir -p /app/data /app/backups
|
||||
|
||||
# Create a placeholder .env file with proper permissions
|
||||
RUN touch /app/.env && chmod 644 /app/.env && chown ${USERNAME}:${USERNAME} /app/.env
|
||||
|
||||
# Set executable permissions
|
||||
RUN chmod +x /app/gomft
|
||||
|
||||
# Set ownership of application files
|
||||
RUN chown -R ${USERNAME}:${USERNAME} /app
|
||||
|
||||
# Expose the application port
|
||||
EXPOSE 8080
|
||||
|
||||
# Use our entrypoint script
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
# Run the application
|
||||
CMD ["/app/gomft"]
|
||||
|
||||
@@ -9,6 +9,7 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
||||
> [!WARNING]
|
||||
> This application is actively under development. As such, any aspect of the application—including configurations, data structures, and database fields—may change rapidly and without prior notice. Please review all release notes thoroughly before updating.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -29,6 +30,8 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple Storage Support**: Leverage rclone's extensive support for cloud storage providers:
|
||||
@@ -80,12 +83,16 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
||||
- **Docker Support**: Easy deployment with Docker images and Docker Compose support
|
||||
- **Portable Deployment**: Run on any platform that supports Docker or Go
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.21 or later
|
||||
- rclone installed and configured
|
||||
- SQLite 3
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Standard Installation
|
||||
@@ -116,6 +123,8 @@ docker pull starfleetcptn/gomft:latest
|
||||
```
|
||||
|
||||
2. Run the container:
|
||||
|
||||
#### Basic run
|
||||
```bash
|
||||
docker run -d \
|
||||
--name gomft \
|
||||
@@ -125,6 +134,42 @@ docker run -d \
|
||||
starfleetcptn/gomft:latest
|
||||
```
|
||||
|
||||
#### Run with specific user ID and group ID (using environment variables)
|
||||
```bash
|
||||
docker run -d \
|
||||
--name gomft \
|
||||
-p 8080:8080 \
|
||||
-v /path/to/data:/app/data \
|
||||
-v /path/to/backups:/app/backups \
|
||||
-e PUID=$(id -u) \
|
||||
-e PGID=$(id -g) \
|
||||
starfleetcptn/gomft:latest
|
||||
```
|
||||
|
||||
#### Or specify user IDs directly
|
||||
```bash
|
||||
docker run -d \
|
||||
--name gomft \
|
||||
-p 8080:8080 \
|
||||
-v /path/to/data:/app/data \
|
||||
-v /path/to/backups:/app/backups \
|
||||
-e PUID=1001 \
|
||||
-e PGID=1001 \
|
||||
starfleetcptn/gomft:latest
|
||||
```
|
||||
|
||||
#### Using a .env file for configuration
|
||||
```bash
|
||||
docker run -d \
|
||||
--name gomft \
|
||||
-p 8080:8080 \
|
||||
-v /path/to/data:/app/data \
|
||||
-v /path/to/backups:/app/backups \
|
||||
-v /path/to/.env:/app/.env \
|
||||
-e PUID=$(id -u) \
|
||||
-e PGID=$(id -g) \
|
||||
starfleetcptn/gomft:latest
|
||||
```
|
||||
3. Access the web interface at `http://localhost:8080`
|
||||
|
||||
#### Docker Compose Example
|
||||
@@ -143,17 +188,19 @@ services:
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./backups:/app/backups
|
||||
- ./.env:/app/.env
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=UTC
|
||||
- SERVER_ADDRESS=:8080
|
||||
- DATA_DIR=/app/data
|
||||
- BACKUP_DIR=/app/backups
|
||||
- JWT_SECRET=change_this_to_a_secure_random_string
|
||||
- BASE_URL=http://localhost:8080
|
||||
# Google OAuth configuration (optional)
|
||||
- GOOGLE_CLIENT_ID=your_google_client_id
|
||||
- GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
# Email configuration
|
||||
- TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
|
||||
- EMAIL_ENABLED=true
|
||||
- EMAIL_HOST=smtp.example.com
|
||||
- EMAIL_PORT=587
|
||||
@@ -163,34 +210,14 @@ services:
|
||||
- EMAIL_REQUIRE_AUTH=true
|
||||
- EMAIL_USERNAME=smtp_username
|
||||
- EMAIL_PASSWORD=smtp_password
|
||||
# Logging configuration
|
||||
- LOGS_DIR=/app/data/logs
|
||||
- LOG_MAX_SIZE=10
|
||||
- LOG_MAX_BACKUPS=5
|
||||
- LOG_MAX_AGE=30
|
||||
- LOG_COMPRESS=true
|
||||
- LOG_LEVEL=info
|
||||
# The user directive is no longer needed when using PUID/PGID environment variables
|
||||
```
|
||||
|
||||
Alternatively, you can mount your own .env file to the container:
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
services:
|
||||
gomft:
|
||||
image: starfleetcptn/gomft:latest
|
||||
container_name: gomft
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./backups:/app/backups
|
||||
- ./.env:/app/.env
|
||||
environment:
|
||||
- TZ=UTC
|
||||
```
|
||||
|
||||
Save this as `docker-compose.yml` and run:
|
||||
|
||||
```bash
|
||||
@@ -199,11 +226,14 @@ docker-compose up -d
|
||||
|
||||
For more information and available tags, visit the [GoMFT Docker Hub page](https://hub.docker.com/r/starfleetcptn/gomft).
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
GoMFT uses an environment file located at `.env` in the root directory of the application. On first run, a default configuration will be created:
|
||||
|
||||
```
|
||||
```ini
|
||||
# Basic configuration
|
||||
SERVER_ADDRESS=:8080
|
||||
DATA_DIR=/app/data
|
||||
BACKUP_DIR=/app/backups
|
||||
@@ -225,6 +255,13 @@ EMAIL_ENABLE_TLS=true
|
||||
EMAIL_REQUIRE_AUTH=true
|
||||
EMAIL_USERNAME=smtp_username
|
||||
EMAIL_PASSWORD=smtp_password
|
||||
|
||||
# Two-Factor Authentication configuration
|
||||
TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
|
||||
|
||||
# UserID and GroupID
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
@@ -249,6 +286,14 @@ EMAIL_PASSWORD=smtp_password
|
||||
- `EMAIL_ENABLE_TLS`: Set to `true` to use TLS for secure email transmission
|
||||
- `EMAIL_REQUIRE_AUTH`: Set to `true` to require authentication for SMTP connections, or `false` for servers that don't need authentication
|
||||
|
||||
- Two-Factor Authentication (2FA) configuration:
|
||||
- `TOTP_ENCRYPTION_KEY`: Secret key used to encrypt/decrypt TOTP secrets (for 2FA)
|
||||
- Should be exactly 32 bytes (characters) for optimal security
|
||||
- If not set, a default development key will be used (not secure for production)
|
||||
- If shorter than 32 bytes, it will be automatically padded (less secure)
|
||||
- If longer than 32 bytes, it will be truncated to 32 bytes
|
||||
- Example: `TOTP_ENCRYPTION_KEY=abcdefghijklmnopqrstuvwxyz123456`
|
||||
|
||||
### Logging Configuration
|
||||
|
||||
GoMFT provides configurable logging with rotation support through the following environment variables:
|
||||
@@ -265,6 +310,8 @@ GoMFT provides configurable logging with rotation support through the following
|
||||
|
||||
Log files contain detailed information about file transfers, job execution, and system operations, which can be useful for troubleshooting and auditing.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
1. Start the server:
|
||||
@@ -340,6 +387,43 @@ User management features:
|
||||
- JWT-based authentication with tokens
|
||||
- User theme preference settings (light/dark)
|
||||
|
||||
### Two-Factor Authentication (2FA) Implementation
|
||||
|
||||
#### Overview
|
||||
This implementation adds TOTP-based (Time-based One-Time Password) two-factor authentication support to the application, compatible with standard authenticator apps like Google Authenticator, Authy, and others.
|
||||
|
||||
#### Features
|
||||
- TOTP-based authentication (RFC 6238 compliant)
|
||||
- QR code setup for easy enrollment
|
||||
- Backup codes for account recovery
|
||||
- Rate-limited verification attempts
|
||||
- Secure secret storage
|
||||
|
||||
#### Database Changes
|
||||
The following fields have been added to the `users` table:
|
||||
- `two_factor_secret`: Stores the TOTP secret key
|
||||
- `two_factor_enabled`: Boolean flag indicating if 2FA is enabled
|
||||
- `backup_codes`: Stores recovery backup codes
|
||||
|
||||
#### Setup Process
|
||||
1. Navigate to `/profile/2fa/setup`
|
||||
2. Scan the displayed QR code with your authenticator app
|
||||
3. Enter the verification code to confirm setup
|
||||
4. Save your backup codes in a secure location
|
||||
|
||||
#### Login Flow
|
||||
1. Enter email and password as usual
|
||||
2. If 2FA is enabled:
|
||||
- Enter the 6-digit code from your authenticator app
|
||||
- Alternatively, use a backup code if you can't access your authenticator
|
||||
|
||||
#### Security Considerations
|
||||
- The TOTP secrets are encrypted using AES-256-GCM
|
||||
- You must set the `TOTP_ENCRYPTION_KEY` environment variable in production
|
||||
- This key should be 32 bytes (characters) long and kept confidential
|
||||
- Changing this key after users have set up 2FA will invalidate their existing 2FA configurations
|
||||
- For high-security deployments, store this key in a secure vault and inject it at runtime
|
||||
|
||||
### Transfer Configuration Options
|
||||
|
||||
1. **Source/Destination Types**:
|
||||
@@ -502,6 +586,8 @@ The Admin Tools interface also includes database management capabilities:
|
||||
- View system statistics
|
||||
- Optimize the database with maintenance tools
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
@@ -552,6 +638,8 @@ templ generate
|
||||
air
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
@@ -560,6 +648,8 @@ air
|
||||
4. Push to the branch
|
||||
5. Create a Pull Request
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
GoMFT uses the following directory structure:
|
||||
@@ -580,6 +670,83 @@ volumes:
|
||||
|
||||
These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `LOGS_DIR`.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Running as a Non-Root User
|
||||
|
||||
By default, Docker containers run as the root user, which can pose security risks. GoMFT supports running as a non-root user, which is recommended for production environments.
|
||||
|
||||
#### Benefits of Running as Non-Root
|
||||
|
||||
- **Improved Security**: Limits the potential damage if the container is compromised
|
||||
- **Better File Permissions**: Files created by the container will match your host user permissions
|
||||
- **Compliance**: Many security policies and best practices require containers to run as non-root
|
||||
|
||||
#### Methods to Run as Non-Root
|
||||
|
||||
1. **Using PUID/PGID environment variables (recommended)**:
|
||||
```bash
|
||||
# Using current user's ID
|
||||
docker run -e PUID=$(id -u) -e PGID=$(id -g) starfleetcptn/gomft:latest
|
||||
|
||||
# Or in docker-compose.yml
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
```
|
||||
This is the most flexible method as it allows changing the user at runtime without rebuilding the image.
|
||||
|
||||
2. **Using the `--user` flag with Docker run**:
|
||||
```bash
|
||||
docker run --user $(id -u):$(id -g) starfleetcptn/gomft:latest
|
||||
```
|
||||
|
||||
3. **Using Docker Compose with environment variables for `user` directive**:
|
||||
```yaml
|
||||
services:
|
||||
gomft:
|
||||
image: starfleetcptn/gomft:latest
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
```
|
||||
|
||||
4. **Building a custom image with specified UID/GID**:
|
||||
```yaml
|
||||
services:
|
||||
gomft:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
UID: ${UID:-1000}
|
||||
GID: ${GID:-1000}
|
||||
```
|
||||
|
||||
#### Environment Variables for User Management
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PUID` | User ID to run as | Built-in user ID (1000) |
|
||||
| `PGID` | Group ID to run as | Built-in group ID (1000) |
|
||||
| `USERNAME` | Username to use | `gomft` |
|
||||
|
||||
These environment variables allow you to change the user/group IDs at runtime without rebuilding the image.
|
||||
|
||||
#### Volume Permissions
|
||||
|
||||
When mounting volumes, ensure that the directories on the host have appropriate permissions for the container user:
|
||||
|
||||
```bash
|
||||
# Create directories with correct ownership
|
||||
mkdir -p data backups
|
||||
chown -R $(id -u):$(id -g) data backups
|
||||
|
||||
# Or adjust permissions to allow the container user to write
|
||||
mkdir -p data backups
|
||||
chmod -R 777 data backups # Less secure, but easier for testing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -36,6 +36,13 @@ type AdminToolsData struct {
|
||||
LogFiles []LogFile
|
||||
LogContent string
|
||||
CurrentLogFile string
|
||||
EmailTestSuccess *bool
|
||||
EmailTestMessage string
|
||||
SmtpServer string
|
||||
WebhookTestSuccess *bool
|
||||
WebhookTestMessage string
|
||||
WebhookStatusCode int
|
||||
WebhookResponse string
|
||||
}
|
||||
|
||||
// Dialog component for confirmation dialogs
|
||||
@@ -463,6 +470,255 @@ templ AdminTools(ctx context.Context, data AdminToolsData) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Testing Tools -->
|
||||
<div class="mt-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-envelope mr-2 text-primary-500"></i>
|
||||
Email Testing
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-sm text-secondary-600 dark:text-secondary-400 mb-4">
|
||||
Test your email configuration by sending a test email to verify the server can send emails properly.
|
||||
</p>
|
||||
|
||||
<form id="test-email-form" hx-post="/admin/test-email" hx-target="#email-test-result" hx-swap="outerHTML" hx-indicator="#email-test-indicator">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="test-email-recipient" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Recipient Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="test-email-recipient"
|
||||
name="recipient"
|
||||
class="form-input w-full"
|
||||
placeholder="recipient@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="test-email-subject" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Subject (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="test-email-subject"
|
||||
name="subject"
|
||||
class="form-input w-full"
|
||||
placeholder="Test Email from GoMFT"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="test-email-message" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Message (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="test-email-message"
|
||||
name="message"
|
||||
rows="3"
|
||||
class="form-textarea w-full"
|
||||
placeholder="This is a test email from GoMFT."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="btn-primary flex items-center justify-center" onclick="fadeInAnimation()">
|
||||
<i class="fas fa-paper-plane mr-2"></i>
|
||||
<span>Send Test Email</span>
|
||||
<div id="email-test-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Toast container for email test results -->
|
||||
<div id="email-test-result" class="mt-4 hidden">
|
||||
if data.EmailTestSuccess != nil {
|
||||
@EmailTestToast(*data.EmailTestSuccess, data.EmailTestMessage)
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Email settings reminder -->
|
||||
<div class="mt-6 bg-secondary-50 dark:bg-secondary-800/50 p-4 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 flex items-center">
|
||||
<i class="fas fa-info-circle mr-2 text-blue-500"></i>
|
||||
Email Configuration
|
||||
</h4>
|
||||
if data.SmtpServer != "" {
|
||||
<p class="mt-2 text-xs text-secondary-600 dark:text-secondary-400">
|
||||
Current SMTP server: <span class="font-mono">{ data.SmtpServer }</span>
|
||||
</p>
|
||||
} else {
|
||||
<p class="mt-2 text-xs text-secondary-600 dark:text-secondary-400">
|
||||
Email settings are configured in your application configuration file. Make sure SMTP settings are properly configured before testing.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Testing Tools -->
|
||||
<div class="mt-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-bell mr-2 text-primary-500"></i>
|
||||
Webhook Notification Testing
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-sm text-secondary-600 dark:text-secondary-400 mb-4">
|
||||
Test webhook notifications by sending a sample job execution payload to your webhook endpoint.
|
||||
</p>
|
||||
|
||||
<form id="webhook-test-form" hx-post="/admin/test-webhook" hx-target="#webhook-test-result" hx-swap="outerHTML" hx-indicator="#webhook-test-indicator">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="webhook-url" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Webhook URL
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-link text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
id="webhook-url"
|
||||
name="webhook_url"
|
||||
class="form-input pl-10 w-full"
|
||||
placeholder="https://example.com/webhook"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="webhook-secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Webhook Secret <span class="text-secondary-500 dark:text-secondary-400">(optional)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="webhook-secret"
|
||||
name="webhook_secret"
|
||||
class="form-input pl-10 w-full"
|
||||
placeholder="Secret token for signing requests"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Used to sign webhook payloads (X-Hub-Signature-256 header)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="webhook-headers" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Custom Headers <span class="text-secondary-500 dark:text-secondary-400">(optional)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-code text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="webhook-headers"
|
||||
name="webhook_headers"
|
||||
class="form-input pl-10 w-full"
|
||||
placeholder='{"X-Custom-Header": "value"}'
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Additional HTTP headers as JSON
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="webhook-payload" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Custom Payload <span class="text-secondary-500 dark:text-secondary-400">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="webhook-payload"
|
||||
name="webhook_payload"
|
||||
rows="5"
|
||||
class="form-textarea w-full font-mono text-sm"
|
||||
placeholder='{
|
||||
"event_type": "job_execution",
|
||||
"job_id": 123,
|
||||
"job_name": "Test Job",
|
||||
"status": "completed"
|
||||
}'></textarea>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Leave empty to use default test payload
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="btn-primary flex items-center justify-center" onclick="fadeInAnimation()">
|
||||
<i class="fas fa-paper-plane mr-2"></i>
|
||||
<span>Send Test Webhook</span>
|
||||
<div id="webhook-test-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Result container for webhook test -->
|
||||
<div id="webhook-test-result" class="mt-4 hidden">
|
||||
if data.WebhookTestSuccess != nil {
|
||||
@WebhookTestToast(*data.WebhookTestSuccess, data.WebhookTestMessage, data.WebhookStatusCode, data.WebhookResponse)
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Sample payload section -->
|
||||
<div class="mt-6 bg-secondary-50 dark:bg-secondary-800/50 p-4 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 flex items-center">
|
||||
<i class="fas fa-info-circle mr-2 text-blue-500"></i>
|
||||
Default Webhook Test Payload
|
||||
</h4>
|
||||
<div class="mt-2 bg-white dark:bg-secondary-900 p-3 rounded border border-secondary-200 dark:border-secondary-700 overflow-auto">
|
||||
<pre class="text-xs text-secondary-600 dark:text-secondary-400 font-mono">{
|
||||
"event_type": "job_execution",
|
||||
"job_id": 123,
|
||||
"job_name": "Test Job",
|
||||
"config_id": 456,
|
||||
"config_name": "Test Config",
|
||||
"status": "completed",
|
||||
"start_time": "2023-06-18T15:30:45Z",
|
||||
"end_time": "2023-06-18T15:35:12Z",
|
||||
"duration_seconds": 267,
|
||||
"history_id": 789,
|
||||
"bytes_transferred": 1048576,
|
||||
"files_transferred": 5,
|
||||
"source": {
|
||||
"type": "local",
|
||||
"path": "/path/to/source"
|
||||
},
|
||||
"destination": {
|
||||
"type": "s3",
|
||||
"path": "bucket/path"
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Backups -->
|
||||
<div id="backups-container" class="mt-8">
|
||||
@BackupsList(data)
|
||||
@@ -513,11 +769,96 @@ templ AdminTools(ctx context.Context, data AdminToolsData) {
|
||||
{ fmt.Sprint(data.TotalJobs) }
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Application Version</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
<div class="flex items-center">
|
||||
<span>{ AppVersion }</span>
|
||||
if AppVersion == "dev" {
|
||||
<a href="https://github.com/starfleetcptn/gomft/releases"
|
||||
class="ml-2 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
|
||||
target="_blank" rel="noopener">
|
||||
<i class="fas fa-external-link-alt text-xs"></i>
|
||||
</a>
|
||||
} else if AppVersion == "1.0.0" {
|
||||
<a href="https://github.com/starfleetcptn/gomft/releases/tag/v1.0.0"
|
||||
class="ml-2 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
|
||||
target="_blank" rel="noopener">
|
||||
<i class="fas fa-external-link-alt text-xs"></i>
|
||||
</a>
|
||||
} else if AppVersion == "1.1.0" {
|
||||
<a href="https://github.com/starfleetcptn/gomft/releases/tag/v1.1.0"
|
||||
class="ml-2 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
|
||||
target="_blank" rel="noopener">
|
||||
<i class="fas fa-external-link-alt text-xs"></i>
|
||||
</a>
|
||||
} else {
|
||||
<a href="https://github.com/starfleetcptn/gomft/releases"
|
||||
class="ml-2 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
|
||||
target="_blank" rel="noopener">
|
||||
<i class="fas fa-external-link-alt text-xs"></i>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Build Information -->
|
||||
<div class="mt-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-code-branch mr-2 text-primary-500"></i>
|
||||
Build Information
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="relative overflow-hidden bg-secondary-50 dark:bg-secondary-900 rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-3">
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Version</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">
|
||||
{ AppVersion }
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Build Time</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">
|
||||
{ getBuildTime() }
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Commit</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">
|
||||
{ getCommit() }
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<a
|
||||
href="https://github.com/starfleetcptn/gomft"
|
||||
class="inline-flex items-center px-4 py-2 text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors"
|
||||
target="_blank" rel="noopener"
|
||||
>
|
||||
<i class="fab fa-github mr-2"></i>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Viewer -->
|
||||
<div id="logs-container" class="mt-8">
|
||||
@AdminLogViewer(data)
|
||||
@@ -897,3 +1238,157 @@ templ AdminLogContent(data AdminToolsData) {
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// getBuildTime returns the build time of the application
|
||||
// This can be set using ldflags during build
|
||||
// Example: go build -ldflags "-X github.com/starfleetcptn/gomft/components.BuildTime=2023-01-01T00:00:00Z"
|
||||
var BuildTime = "unknown"
|
||||
|
||||
func getBuildTime() string {
|
||||
return BuildTime
|
||||
}
|
||||
|
||||
// getCommit returns the commit hash of the application
|
||||
// This can be set using ldflags during build
|
||||
// Example: go build -ldflags "-X github.com/starfleetcptn/gomft/components.Commit=abcdef123456"
|
||||
var Commit = "unknown"
|
||||
|
||||
func getCommit() string {
|
||||
return Commit
|
||||
}
|
||||
|
||||
// EmailTestToast is a component for showing email test results
|
||||
templ EmailTestToast(success bool, message string) {
|
||||
<div id="email-test-result" class="mt-4 animate-fade-in">
|
||||
<div class={
|
||||
"rounded-lg p-4 flex items-start",
|
||||
templ.KV("bg-green-50 dark:bg-green-900/20 border-l-4 border-green-400", success),
|
||||
templ.KV("bg-red-50 dark:bg-red-900/20 border-l-4 border-red-400", !success),
|
||||
}>
|
||||
<div class="flex-shrink-0">
|
||||
if success {
|
||||
<i class="fas fa-check-circle text-green-400"></i>
|
||||
} else {
|
||||
<i class="fas fa-exclamation-circle text-red-400"></i>
|
||||
}
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class={
|
||||
"text-sm font-medium",
|
||||
templ.KV("text-green-800 dark:text-green-300", success),
|
||||
templ.KV("text-red-800 dark:text-red-300", !success),
|
||||
}>
|
||||
if success {
|
||||
Email Sent Successfully
|
||||
} else {
|
||||
Email Sending Failed
|
||||
}
|
||||
</h3>
|
||||
<div class={
|
||||
"mt-1 text-sm",
|
||||
templ.KV("text-green-700 dark:text-green-400", success),
|
||||
templ.KV("text-red-700 dark:text-red-400", !success),
|
||||
}>
|
||||
{ message }
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto pl-3 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('email-test-result').classList.add('hidden');"
|
||||
class={
|
||||
"inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2",
|
||||
templ.KV("text-green-500 hover:bg-green-100 dark:hover:bg-green-900/30 focus:ring-green-500", success),
|
||||
templ.KV("text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 focus:ring-red-500", !success),
|
||||
}
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Add a style for the animate-fade-in animation
|
||||
script fadeInAnimation() {
|
||||
// Add CSS animation if it doesn't exist
|
||||
if (!document.getElementById('fade-in-animation')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'fade-in-animation';
|
||||
style.textContent = `
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
// WebhookTestToast is a component for showing webhook test results
|
||||
templ WebhookTestToast(success bool, message string, statusCode int, responseBody string) {
|
||||
<div id="webhook-test-result" class="mt-4 animate-fade-in">
|
||||
<div class={
|
||||
"rounded-lg p-4 flex items-start",
|
||||
templ.KV("bg-green-50 dark:bg-green-900/20 border-l-4 border-green-400", success),
|
||||
templ.KV("bg-red-50 dark:bg-red-900/20 border-l-4 border-red-400", !success),
|
||||
}>
|
||||
<div class="flex-shrink-0">
|
||||
if success {
|
||||
<i class="fas fa-check-circle text-green-400"></i>
|
||||
} else {
|
||||
<i class="fas fa-exclamation-circle text-red-400"></i>
|
||||
}
|
||||
</div>
|
||||
<div class="ml-3 flex-grow">
|
||||
<h3 class={
|
||||
"text-sm font-medium",
|
||||
templ.KV("text-green-800 dark:text-green-300", success),
|
||||
templ.KV("text-red-800 dark:text-red-300", !success),
|
||||
}>
|
||||
if success {
|
||||
Webhook Sent Successfully
|
||||
} else {
|
||||
Webhook Sending Failed
|
||||
}
|
||||
</h3>
|
||||
<div class={
|
||||
"mt-1 text-sm",
|
||||
templ.KV("text-green-700 dark:text-green-400", success),
|
||||
templ.KV("text-red-700 dark:text-red-400", !success),
|
||||
}>
|
||||
{ message }
|
||||
</div>
|
||||
|
||||
<!-- Additional response details -->
|
||||
<div class="mt-3 text-sm">
|
||||
<div class="font-medium text-secondary-700 dark:text-secondary-300">Status Code: { fmt.Sprint(statusCode) }</div>
|
||||
if responseBody != "" {
|
||||
<div class="mt-2">
|
||||
<div class="font-medium text-secondary-700 dark:text-secondary-300 mb-1">Response:</div>
|
||||
<div class="bg-white dark:bg-secondary-900 p-2 rounded border border-secondary-200 dark:border-secondary-700">
|
||||
<pre class="text-xs text-secondary-600 dark:text-secondary-400 font-mono overflow-auto max-h-40">{ responseBody }</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto pl-3 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('webhook-test-result').classList.add('hidden');"
|
||||
class={
|
||||
"inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2",
|
||||
templ.KV("text-green-500 hover:bg-green-100 dark:hover:bg-green-900/30 focus:ring-green-500", success),
|
||||
templ.KV("text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 focus:ring-red-500", !success),
|
||||
}
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+197
-10
@@ -72,6 +72,172 @@ templ configSearchScript() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Handle job ordering
|
||||
const setupJobOrdering = (configListId, selectedListId, formId, savedOrder) => {
|
||||
const configList = document.getElementById(configListId);
|
||||
const selectedList = document.getElementById(selectedListId);
|
||||
const form = document.getElementById(formId);
|
||||
|
||||
if (!configList || !selectedList || !form) return;
|
||||
|
||||
// Get saved order if available
|
||||
const orderedIds = savedOrder ? savedOrder.split(',').map(id => id.trim()) : [];
|
||||
console.log('Initial saved order:', orderedIds);
|
||||
|
||||
// Initialize selected items from checked checkboxes
|
||||
const updateSelectedItems = (initialLoad = false) => {
|
||||
// Clear current list
|
||||
selectedList.innerHTML = '';
|
||||
|
||||
// Get all checked checkboxes
|
||||
const checkedItems = configList.querySelectorAll('input[type="checkbox"]:checked');
|
||||
|
||||
if (checkedItems.length === 0) {
|
||||
selectedList.innerHTML = '<div class="text-center py-4 text-secondary-500 dark:text-secondary-400">No configurations selected</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a map of config items for easy access
|
||||
const configItems = {};
|
||||
checkedItems.forEach(checkbox => {
|
||||
configItems[checkbox.value] = {
|
||||
checkbox: checkbox,
|
||||
configId: checkbox.value,
|
||||
configName: checkbox.nextElementSibling.textContent.trim()
|
||||
};
|
||||
});
|
||||
|
||||
// If we have a saved order and this is the initial load, use that order
|
||||
let itemsToShow = [];
|
||||
if (initialLoad && orderedIds.length > 0) {
|
||||
// First add items in the saved order
|
||||
orderedIds.forEach(id => {
|
||||
if (configItems[id]) {
|
||||
itemsToShow.push(configItems[id]);
|
||||
delete configItems[id]; // Remove from map to avoid duplicates
|
||||
}
|
||||
});
|
||||
|
||||
// Then add any remaining checked items not in the saved order
|
||||
Object.values(configItems).forEach(item => {
|
||||
itemsToShow.push(item);
|
||||
});
|
||||
} else {
|
||||
// Just add all checked items in their current order
|
||||
itemsToShow = Object.values(configItems);
|
||||
}
|
||||
|
||||
// Add each item to the selected list
|
||||
itemsToShow.forEach((item, index) => {
|
||||
const configId = item.configId;
|
||||
const configName = item.configName;
|
||||
|
||||
const listItem = document.createElement('div');
|
||||
listItem.className = 'flex items-center justify-between p-2 mb-2 bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-700 rounded-lg';
|
||||
listItem.setAttribute('data-id', configId);
|
||||
|
||||
listItem.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<span class="inline-flex items-center justify-center h-6 w-6 rounded-full bg-primary-100 dark:bg-primary-900 mr-2 text-primary-700 dark:text-primary-300 text-sm">${index + 1}</span>
|
||||
<span class="font-medium text-secondary-700 dark:text-secondary-300">${configName}</span>
|
||||
</div>
|
||||
<div class="flex space-x-1">
|
||||
<button type="button" class="move-up p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-700" title="Move up">
|
||||
<i class="fas fa-arrow-up text-secondary-500"></i>
|
||||
</button>
|
||||
<button type="button" class="move-down p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-700" title="Move down">
|
||||
<i class="fas fa-arrow-down text-secondary-500"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
selectedList.appendChild(listItem);
|
||||
});
|
||||
|
||||
// Update hidden order inputs
|
||||
updateOrderInputs();
|
||||
};
|
||||
|
||||
// Update hidden inputs with the current order
|
||||
const updateOrderInputs = () => {
|
||||
const items = selectedList.querySelectorAll('.flex.items-center.justify-between');
|
||||
if (items.length === 0) return;
|
||||
|
||||
// Remove any existing order input to avoid duplicates
|
||||
const existingOrderInput = form.querySelector('input[name="config_order"]');
|
||||
if (existingOrderInput) {
|
||||
existingOrderInput.remove();
|
||||
}
|
||||
|
||||
// Create a new input with the current order
|
||||
const orderedIds = Array.from(items).map(item => item.getAttribute('data-id'));
|
||||
|
||||
// Create a hidden input to store the order
|
||||
const configOrderInput = document.createElement('input');
|
||||
configOrderInput.type = 'hidden';
|
||||
configOrderInput.name = 'config_order';
|
||||
configOrderInput.value = orderedIds.join(',');
|
||||
|
||||
// Add the input to the form
|
||||
form.appendChild(configOrderInput);
|
||||
|
||||
// Update the visible order numbers
|
||||
items.forEach((item, index) => {
|
||||
const orderNum = index + 1;
|
||||
const orderSpan = item.querySelector('span.rounded-full');
|
||||
if (orderSpan) {
|
||||
orderSpan.textContent = orderNum;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Updated order input:', configOrderInput.value);
|
||||
};
|
||||
|
||||
// Initialize the selected list with saved order if available
|
||||
updateSelectedItems(true);
|
||||
|
||||
// Handle checkbox changes
|
||||
configList.addEventListener('change', (e) => {
|
||||
if (e.target.matches('input[type="checkbox"]')) {
|
||||
updateSelectedItems(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle reordering
|
||||
selectedList.addEventListener('click', (e) => {
|
||||
const listItem = e.target.closest('.flex.items-center.justify-between');
|
||||
if (!listItem) return;
|
||||
|
||||
if (e.target.closest('.move-up')) {
|
||||
const prev = listItem.previousElementSibling;
|
||||
if (prev) {
|
||||
selectedList.insertBefore(listItem, prev);
|
||||
updateOrderInputs();
|
||||
}
|
||||
} else if (e.target.closest('.move-down')) {
|
||||
const next = listItem.nextElementSibling;
|
||||
if (next) {
|
||||
selectedList.insertBefore(next, listItem);
|
||||
updateOrderInputs();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure the order input is updated before submission
|
||||
form.addEventListener('submit', function(e) {
|
||||
updateOrderInputs();
|
||||
console.log('Form submitted with order:', form.querySelector('input[name="config_order"]')?.value);
|
||||
});
|
||||
};
|
||||
|
||||
// Setup ordering for new job form
|
||||
setupJobOrdering('config-list', 'selected-configs', 'new-job-form', null);
|
||||
|
||||
// Setup ordering for edit job form
|
||||
const editJobForm = document.getElementById('edit-job-form');
|
||||
const savedOrderEdit = editJobForm ? editJobForm.getAttribute('data-config-order') : null;
|
||||
setupJobOrdering('config-list-edit', 'selected-configs-edit', 'edit-job-form', savedOrderEdit);
|
||||
});
|
||||
</script>
|
||||
}
|
||||
@@ -95,6 +261,7 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
||||
|
||||
if data.IsNew {
|
||||
<form
|
||||
id="new-job-form"
|
||||
class="space-y-6"
|
||||
hx-post="/jobs"
|
||||
hx-target="body"
|
||||
@@ -161,10 +328,19 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Select one or more configurations to run on this schedule.
|
||||
</p>
|
||||
<!-- Selected Configurations Order List -->
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
<i class="fas fa-sort-amount-down mr-1"></i> Execution Order
|
||||
</label>
|
||||
<div id="selected-configs" class="border border-secondary-300 dark:border-secondary-700 rounded-md p-3 min-h-20 bg-secondary-50 dark:bg-secondary-900">
|
||||
<!-- Selected items will be populated by JavaScript -->
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Use the arrows to change the order in which configurations will execute.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -341,10 +517,12 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
||||
</form>
|
||||
} else {
|
||||
<form
|
||||
id="edit-job-form"
|
||||
class="space-y-6"
|
||||
hx-post={ fmt.Sprintf("/jobs/%d", data.Job.ID) }
|
||||
hx-target="body"
|
||||
hx-boost="true">
|
||||
hx-boost="true"
|
||||
data-config-order={ data.Job.ConfigIDs }>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job Name</label>
|
||||
@@ -411,10 +589,19 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Select one or more configurations to run on this schedule.
|
||||
</p>
|
||||
<!-- Selected Configurations Order List for edit -->
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
<i class="fas fa-sort-amount-down mr-1"></i> Execution Order
|
||||
</label>
|
||||
<div id="selected-configs-edit" class="border border-secondary-300 dark:border-secondary-700 rounded-md p-3 min-h-20 bg-secondary-50 dark:bg-secondary-900">
|
||||
<!-- Selected items will be populated by JavaScript -->
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Use the arrows to change the order in which configurations will execute.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -599,7 +786,7 @@ templ JobForm(ctx context.Context, data JobFormData) {
|
||||
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
|
||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Jobs will run according to their schedule and execute the selected transfer configuration
|
||||
Jobs will run according to their schedule and execute the selected transfer configurations in the order specified
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+25
-1
@@ -3,8 +3,20 @@ package components
|
||||
import (
|
||||
"context"
|
||||
"github.com/gin-gonic/gin"
|
||||
"time"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// AppVersion will be set at build time using ldflags
|
||||
// Example build command:
|
||||
// go build -ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=1.2.3"
|
||||
var AppVersion = "dev"
|
||||
|
||||
// GetReleaseURL returns the URL to the specific GitHub release
|
||||
func GetReleaseURL() templ.SafeURL {
|
||||
return templ.SafeURL(fmt.Sprintf("https://github.com/starfleetcptn/gomft/releases/tag/%s", AppVersion))
|
||||
}
|
||||
|
||||
// CreateTemplateContext creates a new context with user information from Gin's context
|
||||
func CreateTemplateContext(c *gin.Context) context.Context {
|
||||
ctx := context.Background()
|
||||
@@ -437,6 +449,18 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
||||
<p class="text-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
GoMFT © { getCurrentYear() } | Secure File Transfer Solution
|
||||
</p>
|
||||
<div class="flex justify-center items-center space-x-4 mt-1">
|
||||
<p class="text-xs text-secondary-400 dark:text-secondary-500">
|
||||
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200" target="_blank" rel="noopener">
|
||||
<i class="fab fa-github mr-1"></i>GitHub
|
||||
</a>
|
||||
</p>
|
||||
<p class="text-xs text-secondary-400 dark:text-secondary-500">
|
||||
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200" target="_blank" rel="noopener">
|
||||
<i class="fas fa-tag mr-1"></i>Version { AppVersion }
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -544,5 +568,5 @@ func getUserEmail(ctx context.Context) string {
|
||||
|
||||
// Helper function to get current year
|
||||
func getCurrentYear() string {
|
||||
return "2025"
|
||||
return time.Now().Format("2006")
|
||||
}
|
||||
@@ -5,8 +5,112 @@ import (
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
// Dialog component for 2FA disable confirmation
|
||||
templ TwoFactorDisableDialog() {
|
||||
<div id="disable-2fa-dialog" class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
||||
<div class="px-6 pt-5 pb-3 text-center">
|
||||
<div class="flex justify-center mb-2">
|
||||
<i class="fas fa-shield-alt text-yellow-400 text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
|
||||
Disable Two-Factor Authentication
|
||||
</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<p class="text-secondary-700 dark:text-secondary-300 mb-4">
|
||||
Are you sure you want to disable two-factor authentication? This will make your account less secure.
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="current-password-2fa" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
<i class="fas fa-lock mr-1"></i> Current Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="current-password-2fa"
|
||||
name="current_password"
|
||||
class="form-input pl-10 w-full"
|
||||
placeholder="Enter your current password"
|
||||
required/>
|
||||
</div>
|
||||
</div>
|
||||
<div id="disable-2fa-result"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 flex justify-end space-x-3">
|
||||
<button type="button" class="btn-secondary" onclick="hideDisable2FADialog()">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
onclick="submitDisable2FA()">
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
Disable 2FA
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ Profile(ctx context.Context, user db.User) {
|
||||
@LayoutWithContext("Profile", ctx) {
|
||||
<script>
|
||||
// Initialize dialog functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('Initializing 2FA dialog functionality');
|
||||
|
||||
// Global functions for dialog control
|
||||
window.hideDisable2FADialog = function() {
|
||||
document.getElementById('disable-2fa-dialog').classList.add('hidden');
|
||||
document.getElementById('current-password-2fa').value = '';
|
||||
document.getElementById('disable-2fa-result').innerHTML = '';
|
||||
};
|
||||
|
||||
window.showDisable2FADialog = function() {
|
||||
console.log('Showing 2FA disable dialog');
|
||||
document.getElementById('disable-2fa-dialog').classList.remove('hidden');
|
||||
};
|
||||
|
||||
window.submitDisable2FA = function() {
|
||||
const password = document.getElementById('current-password-2fa').value;
|
||||
if (!password) {
|
||||
document.getElementById('disable-2fa-result').innerHTML = `
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded" role="alert">
|
||||
<span class="block sm:inline">Current password is required</span>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
htmx.ajax('POST', '/profile/2fa/disable', {
|
||||
target: '#disable-2fa-result',
|
||||
swap: 'innerHTML',
|
||||
values: { current_password: password }
|
||||
});
|
||||
};
|
||||
|
||||
// Close dialog when clicking outside
|
||||
document.getElementById('disable-2fa-dialog').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
hideDisable2FADialog();
|
||||
}
|
||||
});
|
||||
|
||||
// Close dialog on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && !document.getElementById('disable-2fa-dialog').classList.contains('hidden')) {
|
||||
hideDisable2FADialog();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@TwoFactorDisableDialog()
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
@@ -45,6 +149,47 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Two-Factor Authentication</dt>
|
||||
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
||||
if user.TwoFactorEnabled {
|
||||
<div class="flex flex-col space-y-3">
|
||||
<div class="flex items-center">
|
||||
<span class="badge badge-success">
|
||||
<i class="fas fa-shield-alt mr-1"></i> Enabled
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/profile/2fa/backup-codes" class="btn-secondary btn-sm">
|
||||
<i class="fas fa-key mr-1"></i>
|
||||
Manage Backup Codes
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger btn-sm"
|
||||
onclick="showDisable2FADialog()">
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
Disable 2FA
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="flex flex-col space-y-3">
|
||||
<div class="flex items-center">
|
||||
<span class="badge badge-warning">
|
||||
<i class="fas fa-shield-alt mr-1"></i> Disabled
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/profile/2fa/setup" class="btn-primary btn-sm">
|
||||
<i class="fas fa-lock mr-1"></i>
|
||||
Enable 2FA
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Theme</dt>
|
||||
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
||||
|
||||
@@ -74,6 +74,7 @@ templ GooglePhotosDestinationForm() {
|
||||
<input id="dest_read_only" name="dest_read_only" type="checkbox"
|
||||
class="sr-only"
|
||||
x-model="destReadOnly"
|
||||
:value="destReadOnly ? 'true' : 'false'"
|
||||
/>
|
||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
||||
@@ -109,6 +110,7 @@ templ GooglePhotosDestinationForm() {
|
||||
<input id="dest_include_archived" name="dest_include_archived" type="checkbox"
|
||||
class="sr-only"
|
||||
x-model="destIncludeArchived"
|
||||
:value="destIncludeArchived ? 'true' : 'false'"
|
||||
/>
|
||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
||||
|
||||
@@ -74,6 +74,7 @@ templ GooglePhotosSourceForm() {
|
||||
<input id="source_read_only" name="source_read_only" type="checkbox"
|
||||
class="sr-only"
|
||||
x-model="sourceReadOnly"
|
||||
:value="sourceReadOnly ? 'true' : 'false'"
|
||||
/>
|
||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
||||
@@ -109,6 +110,7 @@ templ GooglePhotosSourceForm() {
|
||||
<input id="source_include_archived" name="source_include_archived" type="checkbox"
|
||||
class="sr-only"
|
||||
x-model="sourceIncludeArchived"
|
||||
:value="sourceIncludeArchived ? 'true' : 'false'"
|
||||
/>
|
||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package components
|
||||
|
||||
import "context"
|
||||
import "strings"
|
||||
|
||||
type TwoFactorBackupCodesData struct {
|
||||
BackupCodes []string
|
||||
ErrorMessage string
|
||||
SuccessMessage string
|
||||
}
|
||||
|
||||
templ TwoFactorBackupCodes(ctx context.Context, data TwoFactorBackupCodesData) {
|
||||
@LayoutWithContext("Two-Factor Authentication Backup Codes", ctx) {
|
||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">2FA Backup Codes</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">These codes can be used to login if you lose access to your authenticator app</p>
|
||||
</div>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if data.SuccessMessage != "" {
|
||||
if (strings.Contains(data.SuccessMessage, "IMPORTANT")) {
|
||||
// Show important messages with a different style and icon
|
||||
<div class="bg-yellow-100 dark:bg-yellow-900 border border-yellow-400 dark:border-yellow-700 text-yellow-700 dark:text-yellow-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
<span class="block sm:inline font-bold">{ data.SuccessMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.SuccessMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="space-y-8">
|
||||
if len(data.BackupCodes) > 0 {
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">Your Backup Codes</h3>
|
||||
|
||||
<div class="p-4 border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 rounded-lg mb-6">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
<i class="fas fa-shield-alt text-amber-600 dark:text-amber-400 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h4 class="text-sm font-medium text-amber-800 dark:text-amber-300">Security information</h4>
|
||||
<div class="mt-1 text-sm text-amber-700 dark:text-amber-400 space-y-2">
|
||||
<p><strong>These codes are your backup method to access your account.</strong></p>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Each code can only be used once.</li>
|
||||
<li>Store these codes in a secure password manager.</li>
|
||||
<li>These codes allow access to your account - keep them safe!</li>
|
||||
<li>For security, you can only view codes immediately after generating them.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
for _, code := range data.BackupCodes {
|
||||
if strings.Contains(code, "REDACTED") {
|
||||
<div class="p-2 bg-secondary-200 dark:bg-secondary-700 rounded font-mono text-sm text-center text-secondary-500 dark:text-secondary-400">
|
||||
{ code }
|
||||
</div>
|
||||
} else {
|
||||
<div class="p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm text-center font-bold">
|
||||
{ code }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="flex justify-center space-x-4 mt-6">
|
||||
if !strings.Contains(data.BackupCodes[0], "REDACTED") {
|
||||
<button
|
||||
class="btn-secondary"
|
||||
onclick="downloadBackupCodes(this)">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Download Backup Codes
|
||||
</button>
|
||||
}
|
||||
<form method="POST" action="/profile/2fa/regenerate-codes" class="inline">
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Generate New Codes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
function downloadBackupCodes(button) {
|
||||
// Get backup codes from the displayed elements that don't contain "REDACTED"
|
||||
const codes = Array.from(
|
||||
document.querySelectorAll('.bg-secondary-100.dark\\:bg-secondary-700')
|
||||
).map(el => el.textContent.trim());
|
||||
|
||||
// Create content for the file
|
||||
const content =
|
||||
"2FA BACKUP CODES - KEEP THESE SAFE!\n" +
|
||||
"=====================================\n\n" +
|
||||
codes.join("\n") +
|
||||
"\n\n" +
|
||||
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
|
||||
"SECURITY WARNINGS:\n" +
|
||||
"* These codes can be used to access your account if you lose access to your authenticator app.\n" +
|
||||
"* Each code can only be used once.\n" +
|
||||
"* Keep these codes in a secure location like a password manager.\n" +
|
||||
"* Treat these codes with the same security as your password.";
|
||||
|
||||
// Create blob and download link
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '2fa-backup-codes-secure.txt';
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Cleanup
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
} else {
|
||||
<div class="text-center py-8">
|
||||
<div class="rounded-full bg-secondary-100 dark:bg-secondary-700 p-4 mx-auto w-16 h-16 flex items-center justify-center mb-4">
|
||||
<i class="fas fa-key text-secondary-500 dark:text-secondary-400 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-2">No Backup Codes Available</h3>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-6">You don't have any backup codes. Generate new ones for account recovery.</p>
|
||||
<form method="POST" action="/profile/2fa/regenerate-codes">
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Generate Backup Codes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div class="flex justify-between items-center">
|
||||
<a href="/profile" class="text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package components
|
||||
|
||||
import "context"
|
||||
|
||||
type BackupCodeVerifyData struct {
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
templ TwoFactorBackupVerify(ctx context.Context, data BackupCodeVerifyData) {
|
||||
@LayoutWithContext("Backup Code Verification", ctx) {
|
||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
||||
<div class="max-w-md mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-amber-100 dark:bg-amber-900 mb-4">
|
||||
<i class="fas fa-key text-amber-600 dark:text-amber-400 text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Backup Code Verification</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter one of your backup codes</p>
|
||||
</div>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/login/verify"
|
||||
class="space-y-6"
|
||||
x-data="{ code: '', loading: false }"
|
||||
@submit="loading = true">
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Backup Code
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
x-model="code"
|
||||
class="form-input pl-10 w-full"
|
||||
placeholder="Enter backup code"
|
||||
required
|
||||
autocomplete="one-time-code"/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-amber-600 dark:text-amber-400">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Remember that each backup code can only be used once!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full"
|
||||
x-bind:disabled="!code.trim() || loading">
|
||||
<span x-show="!loading">Verify</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/login/verify" class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">
|
||||
Use authenticator app instead
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package components
|
||||
|
||||
import "context"
|
||||
|
||||
type TwoFactorSetupData struct {
|
||||
QRCodeURL string
|
||||
Secret string
|
||||
BackupCodes []string
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
templ TwoFactorSetup(ctx context.Context, data TwoFactorSetupData) {
|
||||
@LayoutWithContext("Two-Factor Authentication Setup", ctx) {
|
||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Set Up Two-Factor Authentication</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enhance your account security with 2FA</p>
|
||||
</div>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">1. Scan QR Code</h3>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-4">
|
||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
|
||||
</p>
|
||||
<div class="flex justify-center mb-4">
|
||||
<img src={ data.QRCodeURL } alt="QR Code" class="border border-secondary-200 dark:border-secondary-700 rounded-lg p-2 bg-white"/>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Can't scan the QR code? Use this code instead:
|
||||
</p>
|
||||
<code class="block mt-2 p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm">
|
||||
{ data.Secret }
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">2. Verify Setup</h3>
|
||||
<form
|
||||
method="POST"
|
||||
action="/profile/2fa/verify"
|
||||
class="space-y-4"
|
||||
x-data="{ code: '', loading: false }"
|
||||
@submit="loading = true">
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
x-model="code"
|
||||
class="form-input block w-full"
|
||||
pattern="[0-9]*"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
required/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full"
|
||||
x-bind:disabled="code.length !== 6 || loading">
|
||||
<span x-show="!loading">Verify and Enable 2FA</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
if len(data.BackupCodes) > 0 {
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">3. Save Backup Codes</h3>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-4">
|
||||
Store these backup codes in a safe place. You can use them to access your account if you lose your authenticator device.
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
for _, code := range data.BackupCodes {
|
||||
<div class="p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm text-center">
|
||||
{ code }
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
onclick="downloadBackupCodes(this)">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Download Backup Codes
|
||||
</button>
|
||||
<script>
|
||||
function downloadBackupCodes(button) {
|
||||
// Get backup codes from the displayed elements
|
||||
const codes = Array.from(
|
||||
document.querySelectorAll('.bg-secondary-100.dark\\:bg-secondary-700')
|
||||
).map(el => el.textContent.trim());
|
||||
|
||||
// Create content for the file
|
||||
const content =
|
||||
"2FA Backup Codes - Keep these safe!\n" +
|
||||
"=====================================\n\n" +
|
||||
codes.join("\n") +
|
||||
"\n\n" +
|
||||
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
|
||||
"These codes can be used to access your account if you lose access to your authenticator app.\n" +
|
||||
"Each code can only be used once. Keep these codes safe and secure.";
|
||||
|
||||
// Create blob and download link
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '2fa-backup-codes.txt';
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Cleanup
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package components
|
||||
|
||||
import "context"
|
||||
|
||||
type TwoFactorVerifyData struct {
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
templ TwoFactorVerify(ctx context.Context, data TwoFactorVerifyData) {
|
||||
@LayoutWithContext("Two-Factor Authentication", ctx) {
|
||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
||||
<div class="max-w-md mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
||||
<i class="fas fa-shield-alt text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Two-Factor Authentication</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter the code from your authenticator app</p>
|
||||
</div>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/login/verify"
|
||||
class="space-y-6"
|
||||
x-data="{ code: '', loading: false }"
|
||||
@submit="loading = true">
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Authentication Code
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
x-model="code"
|
||||
class="form-input pl-10 w-full"
|
||||
pattern="[0-9]*"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="Enter 6-digit code"
|
||||
required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full"
|
||||
x-bind:disabled="code.length !== 6 || loading">
|
||||
<span x-show="!loading">Verify</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/login/backup-code" class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">
|
||||
Use a backup code instead
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Lost your device?
|
||||
<br/>
|
||||
You can use one of your backup codes instead of the 6-digit code.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,15 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# Set the UID/GID to match your host user for better file permissions
|
||||
# Default is 1000:1000 if not specified
|
||||
UID: ${UID:-1000}
|
||||
GID: ${GID:-1000}
|
||||
# Version information
|
||||
VERSION: ${VERSION:-dev}
|
||||
BUILD_TIME: ${BUILD_TIME:-unknown}
|
||||
COMMIT: ${COMMIT:-unknown}
|
||||
container_name: gomft
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -22,6 +31,9 @@ services:
|
||||
# - LOG_LEVEL=info
|
||||
networks:
|
||||
- gomft-network
|
||||
# For non-root installs, the container needs to run with the same UID
|
||||
# as the host user to access mounted volumes properly
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
|
||||
networks:
|
||||
gomft-network:
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Default username
|
||||
USERNAME=${USERNAME:-gomft}
|
||||
|
||||
# If PUID/PGID env vars are set, update the user's UID/GID
|
||||
if [ -n "${PUID}" ] && [ -n "${PGID}" ]; then
|
||||
echo "🔒 Updating user ${USERNAME} with UID:GID = ${PUID}:${PGID}"
|
||||
|
||||
# Make sure we have directories to work with
|
||||
mkdir -p /app/data /app/backups
|
||||
|
||||
# Check if we're on Alpine (busybox)
|
||||
if grep -q "Alpine" /etc/os-release 2>/dev/null; then
|
||||
echo "Detected Alpine Linux, using busybox usermod/groupmod..."
|
||||
|
||||
# Update group ID first
|
||||
if [ "$(getent group ${USERNAME} | cut -d: -f3)" != "${PGID}" ]; then
|
||||
echo "Updating GID to ${PGID}..."
|
||||
groupmod -g ${PGID} ${USERNAME} || echo "⚠️ Failed to change GID"
|
||||
fi
|
||||
|
||||
# Update user ID
|
||||
if [ "$(id -u ${USERNAME})" != "${PUID}" ]; then
|
||||
echo "Updating UID to ${PUID}..."
|
||||
usermod -u ${PUID} ${USERNAME} || echo "⚠️ Failed to change UID"
|
||||
fi
|
||||
else
|
||||
echo "Non-Alpine system, using standard user management..."
|
||||
# Handle user/group changes with error recovery
|
||||
{
|
||||
# First remove the user (since user has the group as primary group)
|
||||
if getent passwd ${USERNAME} > /dev/null; then
|
||||
echo "Removing existing user ${USERNAME}"
|
||||
userdel ${USERNAME} 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Wait a moment for system to clean up user
|
||||
sleep 1
|
||||
|
||||
# Then remove the group
|
||||
if getent group ${USERNAME} > /dev/null; then
|
||||
echo "Removing existing group ${USERNAME}"
|
||||
groupdel ${USERNAME} 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Recreate group and user in the correct order
|
||||
echo "Creating group ${USERNAME} with GID ${PGID}"
|
||||
groupadd -g ${PGID} ${USERNAME} 2>/dev/null || groupadd ${USERNAME} 2>/dev/null || true
|
||||
|
||||
echo "Creating user ${USERNAME} with UID ${PUID}"
|
||||
useradd -u ${PUID} -g ${USERNAME} -s /bin/sh ${USERNAME} 2>/dev/null ||
|
||||
useradd -g ${USERNAME} -s /bin/sh ${USERNAME} 2>/dev/null || true
|
||||
} || {
|
||||
echo "⚠️ Warning: Failed to update UID/GID, continuing with built-in user"
|
||||
}
|
||||
fi
|
||||
|
||||
# Fix ownership of app directories
|
||||
echo "Setting ownership of app directories"
|
||||
chown -R ${USERNAME}:${USERNAME} /app/data /app/backups || echo "⚠️ Warning: Failed to change ownership"
|
||||
|
||||
# Ensure .env file exists and has correct permissions
|
||||
if [ -f /app/.env ]; then
|
||||
echo "Found .env file, setting permissions..."
|
||||
chown ${USERNAME}:${USERNAME} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
|
||||
chmod 644 /app/.env || echo "⚠️ Warning: Failed to change .env permissions"
|
||||
else
|
||||
echo "No .env file found, creating empty one..."
|
||||
touch /app/.env
|
||||
chown ${USERNAME}:${USERNAME} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
|
||||
chmod 644 /app/.env || echo "⚠️ Warning: Failed to change .env permissions"
|
||||
fi
|
||||
|
||||
# Run the application as the specified user
|
||||
echo "Starting application as user ${USERNAME}"
|
||||
if command -v su-exec >/dev/null 2>&1; then
|
||||
exec su-exec ${USERNAME} "$@"
|
||||
elif command -v gosu >/dev/null 2>&1; then
|
||||
exec gosu ${USERNAME} "$@"
|
||||
else
|
||||
exec su -m ${USERNAME} -c "$*"
|
||||
fi
|
||||
else
|
||||
# Run as the predefined user (set during build)
|
||||
echo "Starting application with predefined user"
|
||||
exec "$@"
|
||||
fi
|
||||
@@ -4,11 +4,13 @@ go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.3.833
|
||||
github.com/gin-contrib/sessions v1.0.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.3
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/crypto v0.35.0
|
||||
@@ -17,13 +19,13 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bytedance/sonic v1.12.9 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sessions v1.0.2 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
|
||||
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/sonic v1.12.9 h1:Od1BvK55NnewtGaJsTDeAOSnLVO2BTSLOe0+ooKokmQ=
|
||||
github.com/bytedance/sonic v1.12.9/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
@@ -42,6 +44,8 @@ github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
@@ -77,6 +81,8 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
// "github.com/pquerna/otp/base32"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/starfleetcptn/gomft/internal/config"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
// IssuerName is the name of the issuer that appears in authenticator apps
|
||||
IssuerName = "GoMFT"
|
||||
// SecretSize is the size of the TOTP secret in bytes
|
||||
SecretSize = 20
|
||||
// BackupCodeCount is the number of backup codes to generate
|
||||
BackupCodeCount = 8
|
||||
// BackupCodeLength is the length of each backup code
|
||||
BackupCodeLength = 8
|
||||
)
|
||||
|
||||
// EncryptTOTPSecret encrypts the TOTP secret with AES-256-GCM
|
||||
func EncryptTOTPSecret(secret string) (string, error) {
|
||||
// Get encryption key from config or environment variable
|
||||
var key []byte
|
||||
appConfig, err := config.Load()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Use the configured encryption key
|
||||
key = []byte(appConfig.TOTPEncryptKey)
|
||||
|
||||
// If empty for some reason, log warning and use development key
|
||||
if len(key) == 0 {
|
||||
fmt.Println("WARNING: Using development encryption key for TOTP. Set TOTP_ENCRYPTION_KEY for production.")
|
||||
key = []byte("this-is-a-dev-key-not-for-production!")
|
||||
}
|
||||
|
||||
// Ensure key is exactly 32 bytes (AES-256)
|
||||
if len(key) < 32 {
|
||||
// If key is too short, pad it to 32 bytes
|
||||
paddedKey := make([]byte, 32)
|
||||
copy(paddedKey, key)
|
||||
for i := len(key); i < 32; i++ {
|
||||
paddedKey[i] = byte(i % 256) // Simple padding pattern
|
||||
}
|
||||
key = paddedKey
|
||||
fmt.Println("WARNING: TOTP encryption key was padded to 32 bytes. This is insecure.")
|
||||
} else if len(key) > 32 {
|
||||
// If key is too long, truncate to 32 bytes
|
||||
key = key[:32]
|
||||
fmt.Println("WARNING: TOTP encryption key was truncated to 32 bytes.")
|
||||
}
|
||||
|
||||
// Create a new cipher block
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cipher: %v", err)
|
||||
}
|
||||
|
||||
// Create a new GCM
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GCM: %v", err)
|
||||
}
|
||||
|
||||
// Create a nonce
|
||||
nonce := make([]byte, aesGCM.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", fmt.Errorf("failed to generate nonce: %v", err)
|
||||
}
|
||||
|
||||
// Encrypt the data
|
||||
ciphertext := aesGCM.Seal(nil, nonce, []byte(secret), nil)
|
||||
|
||||
// Combine nonce and ciphertext and encode as base64
|
||||
result := base64.StdEncoding.EncodeToString(append(nonce, ciphertext...))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DecryptTOTPSecret decrypts the TOTP secret with AES-256-GCM
|
||||
func DecryptTOTPSecret(encryptedSecret string) (string, error) {
|
||||
// Get encryption key from config or environment variable
|
||||
var key []byte
|
||||
appConfig, err := config.Load()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Use the configured encryption key
|
||||
key = []byte(appConfig.TOTPEncryptKey)
|
||||
|
||||
// If empty for some reason, log warning and use development key
|
||||
if len(key) == 0 {
|
||||
fmt.Println("WARNING: Using development encryption key for TOTP. Set TOTP_ENCRYPTION_KEY for production.")
|
||||
key = []byte("this-is-a-dev-key-not-for-production!")
|
||||
}
|
||||
|
||||
// Ensure key is exactly 32 bytes (AES-256)
|
||||
if len(key) < 32 {
|
||||
// If key is too short, pad it to 32 bytes
|
||||
paddedKey := make([]byte, 32)
|
||||
copy(paddedKey, key)
|
||||
for i := len(key); i < 32; i++ {
|
||||
paddedKey[i] = byte(i % 256) // Simple padding pattern
|
||||
}
|
||||
key = paddedKey
|
||||
fmt.Println("WARNING: TOTP encryption key was padded to 32 bytes. This is insecure.")
|
||||
} else if len(key) > 32 {
|
||||
// If key is too long, truncate to 32 bytes
|
||||
key = key[:32]
|
||||
fmt.Println("WARNING: TOTP encryption key was truncated to 32 bytes.")
|
||||
}
|
||||
|
||||
// Decode the base64 string
|
||||
decoded, err := base64.StdEncoding.DecodeString(encryptedSecret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode base64 secret: %v", err)
|
||||
}
|
||||
|
||||
// Create a new cipher block
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cipher: %v", err)
|
||||
}
|
||||
|
||||
// Create a new GCM
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GCM: %v", err)
|
||||
}
|
||||
|
||||
// Get the nonce size
|
||||
nonceSize := aesGCM.NonceSize()
|
||||
if len(decoded) < nonceSize {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
// Extract nonce and ciphertext
|
||||
nonce, ciphertext := decoded[:nonceSize], decoded[nonceSize:]
|
||||
|
||||
// Decrypt the data
|
||||
plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decrypt data: %v", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// GenerateTOTPSecret generates a new TOTP secret for a user
|
||||
func GenerateTOTPSecret(email string) (string, string, error) {
|
||||
// Generate TOTP key using the library
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: IssuerName,
|
||||
AccountName: email,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to generate TOTP key: %v", err)
|
||||
}
|
||||
|
||||
// Generate QR code image
|
||||
var buf bytes.Buffer
|
||||
img, err := key.Image(256, 256)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to generate QR code image: %v", err)
|
||||
}
|
||||
|
||||
// Encode image as PNG and convert to base64
|
||||
err = png.Encode(&buf, img)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to encode QR code image: %v", err)
|
||||
}
|
||||
|
||||
// Create data URL
|
||||
dataURL := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(buf.Bytes()))
|
||||
|
||||
return key.Secret(), dataURL, nil
|
||||
}
|
||||
|
||||
// ValidateTOTPCode validates a TOTP code against an encrypted secret
|
||||
func ValidateTOTPCode(encryptedSecret string, code string) bool {
|
||||
// Remove any spaces from the code
|
||||
code = strings.ReplaceAll(code, " ", "")
|
||||
|
||||
// Decrypt the secret
|
||||
secret, err := DecryptTOTPSecret(encryptedSecret)
|
||||
if err != nil {
|
||||
// Log the error but fail silently to the user
|
||||
fmt.Printf("Error decrypting TOTP secret: %v\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Use the library's Validate function
|
||||
return totp.Validate(code, secret)
|
||||
}
|
||||
|
||||
// BackupCodePair represents a backup code and its hash
|
||||
type BackupCodePair struct {
|
||||
PlainCode string
|
||||
HashedCode string
|
||||
}
|
||||
|
||||
// GenerateBackupCodes generates a set of backup codes
|
||||
// Returns both plaintext codes (to show to user) and hashed codes (to store in DB)
|
||||
func GenerateBackupCodes() ([]string, string, error) {
|
||||
plainCodes := make([]string, BackupCodeCount)
|
||||
hashedCodes := make([]string, BackupCodeCount)
|
||||
|
||||
for i := 0; i < BackupCodeCount; i++ {
|
||||
// Generate random bytes
|
||||
bytes := make([]byte, BackupCodeLength/2)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate backup code: %v", err)
|
||||
}
|
||||
|
||||
// Convert to hex string
|
||||
plainCodes[i] = fmt.Sprintf("%x", bytes)
|
||||
|
||||
// Hash the code for storage
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plainCodes[i]), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to hash backup code: %v", err)
|
||||
}
|
||||
|
||||
// Store the hashed version
|
||||
hashedCodes[i] = string(hash)
|
||||
}
|
||||
|
||||
// Return plaintext codes for display and hashed codes for storage
|
||||
return plainCodes, strings.Join(hashedCodes, ","), nil
|
||||
}
|
||||
|
||||
// ValidateBackupCode validates a backup code against a list of hashed codes
|
||||
func ValidateBackupCode(providedCode string, storedHashedCodes string) bool {
|
||||
if storedHashedCodes == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Remove any spaces and convert to lowercase
|
||||
providedCode = strings.ToLower(strings.ReplaceAll(providedCode, " ", ""))
|
||||
|
||||
// Split stored hashed codes
|
||||
hashedCodes := strings.Split(storedHashedCodes, ",")
|
||||
|
||||
// Check if the provided code matches any stored hashed code
|
||||
for _, hashedCode := range hashedCodes {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hashedCode), []byte(providedCode)); err == nil {
|
||||
// If the code matches (no error from bcrypt), return true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// RemoveBackupCode removes a used backup code from the list
|
||||
func RemoveBackupCode(usedCode string, storedHashedCodes string) string {
|
||||
if storedHashedCodes == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
usedCode = strings.ToLower(strings.ReplaceAll(usedCode, " ", ""))
|
||||
hashedCodes := strings.Split(storedHashedCodes, ",")
|
||||
|
||||
var remainingHashedCodes []string
|
||||
for _, hashedCode := range hashedCodes {
|
||||
// Only add the code back to the list if it doesn't match the used code
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hashedCode), []byte(usedCode)); err != nil {
|
||||
// If there's an error, this isn't the used code, so keep it
|
||||
remainingHashedCodes = append(remainingHashedCodes, hashedCode)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(remainingHashedCodes, ",")
|
||||
}
|
||||
|
||||
// GenerateQRCodeURL generates a QR code URL for an existing secret
|
||||
func GenerateQRCodeURL(secret string, email string) (string, error) {
|
||||
// Decode the base32 secret
|
||||
secretBytes, err := base32.StdEncoding.DecodeString(secret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode secret: %v", err)
|
||||
}
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: IssuerName,
|
||||
AccountName: email,
|
||||
Secret: secretBytes,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate TOTP key: %v", err)
|
||||
}
|
||||
|
||||
// Generate QR code image
|
||||
var buf bytes.Buffer
|
||||
img, err := key.Image(256, 256)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate QR code image: %v", err)
|
||||
}
|
||||
|
||||
// Encode image as PNG and convert to base64
|
||||
err = png.Encode(&buf, img)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode QR code image: %v", err)
|
||||
}
|
||||
|
||||
// Create data URL
|
||||
dataURL := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(buf.Bytes()))
|
||||
|
||||
return dataURL, nil
|
||||
}
|
||||
+23
-11
@@ -9,12 +9,13 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ServerAddress string `json:"server_address"`
|
||||
DataDir string `json:"data_dir"`
|
||||
BackupDir string `json:"backup_dir"`
|
||||
JWTSecret string `json:"jwt_secret"`
|
||||
Email EmailConfig `json:"email"`
|
||||
BaseURL string `json:"base_url"` // Base URL for generating links in emails
|
||||
ServerAddress string `json:"server_address"`
|
||||
DataDir string `json:"data_dir"`
|
||||
BackupDir string `json:"backup_dir"`
|
||||
JWTSecret string `json:"jwt_secret"`
|
||||
Email EmailConfig `json:"email"`
|
||||
BaseURL string `json:"base_url"` // Base URL for generating links in emails
|
||||
TOTPEncryptKey string `json:"totp_encrypt_key"` // Encryption key for TOTP secrets
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
@@ -33,11 +34,12 @@ type EmailConfig struct {
|
||||
func Load() (*Config, error) {
|
||||
// Default configuration
|
||||
cfg := &Config{
|
||||
ServerAddress: ":8080",
|
||||
DataDir: "./data",
|
||||
BackupDir: "./backups",
|
||||
JWTSecret: "change_this_to_a_secure_random_string",
|
||||
BaseURL: "http://localhost:8080",
|
||||
ServerAddress: ":8080",
|
||||
DataDir: "./data",
|
||||
BackupDir: "./backups",
|
||||
JWTSecret: "change_this_to_a_secure_random_string",
|
||||
BaseURL: "http://localhost:8080",
|
||||
TOTPEncryptKey: "this-is-a-dev-key-not-for-production!", // Default development key
|
||||
Email: EmailConfig{
|
||||
Enabled: false,
|
||||
Host: "smtp.example.com",
|
||||
@@ -80,6 +82,9 @@ func Load() (*Config, error) {
|
||||
if baseURL := os.Getenv("BASE_URL"); baseURL != "" {
|
||||
cfg.BaseURL = baseURL
|
||||
}
|
||||
if totpKey := os.Getenv("TOTP_ENCRYPTION_KEY"); totpKey != "" {
|
||||
cfg.TOTPEncryptKey = totpKey
|
||||
}
|
||||
|
||||
// Email configuration
|
||||
if emailEnabled := os.Getenv("EMAIL_ENABLED"); emailEnabled != "" {
|
||||
@@ -125,6 +130,13 @@ func Load() (*Config, error) {
|
||||
"JWT_SECRET=" + cfg.JWTSecret,
|
||||
"BASE_URL=" + cfg.BaseURL,
|
||||
"",
|
||||
"# Google OAuth configuration (optional, for built-in authentication)",
|
||||
"GOOGLE_CLIENT_ID=your_google_client_id",
|
||||
"GOOGLE_CLIENT_SECRET=your_google_client_secret",
|
||||
"",
|
||||
"# Two-Factor Authentication configuration",
|
||||
"TOTP_ENCRYPTION_KEY=" + cfg.TOTPEncryptKey,
|
||||
"",
|
||||
"# Email configuration",
|
||||
"EMAIL_ENABLED=" + strconv.FormatBool(cfg.Email.Enabled),
|
||||
"EMAIL_HOST=" + cfg.Email.Host,
|
||||
|
||||
+40
-2
@@ -2,6 +2,7 @@ package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -25,6 +26,9 @@ type User struct {
|
||||
AccountLocked *bool `gorm:"default:false"`
|
||||
LockoutUntil *time.Time
|
||||
Theme string `gorm:"default:'light'"`
|
||||
TwoFactorSecret string `gorm:"type:varchar(32)"`
|
||||
TwoFactorEnabled bool `gorm:"default:false"`
|
||||
BackupCodes string `gorm:"type:text"` // Comma-separated backup codes
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -183,6 +187,9 @@ func (j *Job) SetConfigIDsList(ids []uint) {
|
||||
// Join with commas
|
||||
j.ConfigIDs = strings.Join(strIDs, ",")
|
||||
|
||||
// Debug log the final ConfigIDs string
|
||||
log.Printf("SetConfigIDsList: Setting ConfigIDs to: %s (from %v)", j.ConfigIDs, ids)
|
||||
|
||||
// If there's at least one ID, set ConfigID to the first one for backward compatibility
|
||||
if len(ids) > 0 {
|
||||
j.ConfigID = ids[0]
|
||||
@@ -373,8 +380,25 @@ func (db *DB) GetJob(id uint) (*Job, error) {
|
||||
}
|
||||
|
||||
func (db *DB) UpdateJob(job *Job) error {
|
||||
log.Printf("UpdateJob: Updating job ID: %d, ConfigIDs: %s", job.ID, job.ConfigIDs)
|
||||
|
||||
// Use Omit to prevent GORM from updating or creating a new config
|
||||
return db.Omit("Config").Save(job).Error
|
||||
return db.Model(&Job{}).
|
||||
Where("id = ?", job.ID).
|
||||
Omit("Config").
|
||||
Updates(map[string]interface{}{
|
||||
"name": job.Name,
|
||||
"config_id": job.ConfigID,
|
||||
"config_ids": job.ConfigIDs, // Explicitly update config_ids
|
||||
"schedule": job.Schedule,
|
||||
"enabled": job.Enabled,
|
||||
"webhook_enabled": job.WebhookEnabled,
|
||||
"webhook_url": job.WebhookURL,
|
||||
"webhook_secret": job.WebhookSecret,
|
||||
"webhook_headers": job.WebhookHeaders,
|
||||
"notify_on_success": job.NotifyOnSuccess,
|
||||
"notify_on_failure": job.NotifyOnFailure,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (db *DB) DeleteJob(id uint) error {
|
||||
@@ -962,7 +986,21 @@ func (db *DB) GetConfigsForJob(jobID uint) ([]TransferConfig, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return configs, nil
|
||||
// Create a map for quick lookup
|
||||
configMap := make(map[uint]TransferConfig)
|
||||
for _, config := range configs {
|
||||
configMap[config.ID] = config
|
||||
}
|
||||
|
||||
// Create a new slice with configs in the correct order
|
||||
orderedConfigs := make([]TransferConfig, 0, len(configs))
|
||||
for _, configID := range configIDs {
|
||||
if config, exists := configMap[configID]; exists {
|
||||
orderedConfigs = append(orderedConfigs, config)
|
||||
}
|
||||
}
|
||||
|
||||
return orderedConfigs, nil
|
||||
}
|
||||
|
||||
// GetSkipProcessedFiles returns the value of SkipProcessedFiles with a default if nil
|
||||
|
||||
@@ -3,6 +3,7 @@ package migrations
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
@@ -33,8 +34,25 @@ func InitialSchema() *gormigrate.Migration {
|
||||
return fmt.Errorf("failed to get database path: %v", err)
|
||||
}
|
||||
|
||||
// Create backup file with timestamp
|
||||
backupFile := fmt.Sprintf("%s.backup.%s", dbPath, time.Now().Format("20060102_150405"))
|
||||
// Get backup directory from environment variable or use default
|
||||
backupDir := os.Getenv("BACKUP_DIR")
|
||||
if backupDir == "" {
|
||||
backupDir = "/app/backups" // Default Docker path
|
||||
// Check if we're not in Docker
|
||||
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||
backupDir = "backups" // Fallback to local directory
|
||||
}
|
||||
}
|
||||
|
||||
// Create backup directory if it doesn't exist
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create backup directory: %v", err)
|
||||
}
|
||||
|
||||
// Create backup file with timestamp in the backup directory
|
||||
dbFileName := filepath.Base(dbPath)
|
||||
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
|
||||
backupFile := filepath.Join(backupDir, backupFileName)
|
||||
|
||||
// Read original database
|
||||
data, err := os.ReadFile(dbPath)
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UpdateBuiltinAuthFields updates the use_builtin_auth field to separate source and destination fields
|
||||
func UpdateBuiltinAuthFields() *gormigrate.Migration {
|
||||
return &gormigrate.Migration{
|
||||
ID: "002_update_builtin_auth_fields",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
// First, add the new columns
|
||||
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN use_builtin_auth_source BOOLEAN`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN use_builtin_auth_dest BOOLEAN`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy the old value to both new columns
|
||||
if err := tx.Exec(`UPDATE transfer_configs SET
|
||||
use_builtin_auth_source = use_builtin_auth,
|
||||
use_builtin_auth_dest = use_builtin_auth`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop the old column
|
||||
return tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN use_builtin_auth`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
// Add back the original column
|
||||
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN use_builtin_auth BOOLEAN`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy the source value back (could also use dest, they should be the same)
|
||||
if err := tx.Exec(`UPDATE transfer_configs SET use_builtin_auth = use_builtin_auth_source`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop the new columns
|
||||
if err := tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN use_builtin_auth_source`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN use_builtin_auth_dest`).Error
|
||||
},
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ import (
|
||||
// UpdateGDriveType updates the source_type and destination_type from 'google_drive' to 'gdrive'
|
||||
func UpdateGDriveType() *gormigrate.Migration {
|
||||
return &gormigrate.Migration{
|
||||
ID: "003_update_gdrive_type",
|
||||
ID: "002_update_gdrive_type",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
// Update source_type
|
||||
if err := tx.Exec(`UPDATE transfer_configs SET source_type = 'gdrive' WHERE source_type = 'google_drive'`).Error; err != nil {
|
||||
@@ -0,0 +1,98 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Add2FA creates a migration for adding Two-Factor Authentication fields
|
||||
func Add2FA() *gormigrate.Migration {
|
||||
return &gormigrate.Migration{
|
||||
ID: "003_add_2fa",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
// Check if any tables exist (indicating an existing database)
|
||||
var count int64
|
||||
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
|
||||
return fmt.Errorf("failed to check for existing tables: %v", err)
|
||||
}
|
||||
|
||||
// If tables exist, create a backup
|
||||
if count > 0 {
|
||||
// Get the database path
|
||||
sqlDB, err := tx.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get underlying database: %v", err)
|
||||
}
|
||||
|
||||
var seq int
|
||||
var name, dbPath string
|
||||
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
|
||||
return fmt.Errorf("failed to get database path: %v", err)
|
||||
}
|
||||
|
||||
// Get backup directory from environment variable or use default
|
||||
backupDir := os.Getenv("BACKUP_DIR")
|
||||
if backupDir == "" {
|
||||
backupDir = "/app/backups" // Default Docker path
|
||||
// Check if we're not in Docker
|
||||
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||
backupDir = "backups" // Fallback to local directory
|
||||
}
|
||||
}
|
||||
|
||||
// Create backup directory if it doesn't exist
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create backup directory: %v", err)
|
||||
}
|
||||
|
||||
// Create backup file with timestamp in the backup directory
|
||||
dbFileName := filepath.Base(dbPath)
|
||||
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
|
||||
backupFile := filepath.Join(backupDir, backupFileName)
|
||||
|
||||
// Read original database
|
||||
data, err := os.ReadFile(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read database for backup: %v", err)
|
||||
}
|
||||
|
||||
// Write backup
|
||||
if err := os.WriteFile(backupFile, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to create database backup: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Created database backup at: %s\n", backupFile)
|
||||
}
|
||||
|
||||
// Add new columns for 2FA - one at a time for SQLite compatibility
|
||||
if err := tx.Exec(`ALTER TABLE users ADD COLUMN two_factor_secret VARCHAR(32)`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN DEFAULT FALSE`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE users ADD COLUMN backup_codes TEXT`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
// Remove 2FA columns - one at a time for SQLite compatibility
|
||||
if err := tx.Exec(`ALTER TABLE users DROP COLUMN two_factor_secret`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE users DROP COLUMN two_factor_enabled`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE users DROP COLUMN backup_codes`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
func InitMigrations(db *gorm.DB) *gormigrate.Gormigrate {
|
||||
migrations := []*gormigrate.Migration{
|
||||
InitialSchema(),
|
||||
UpdateBuiltinAuthFields(),
|
||||
UpdateGDriveType(),
|
||||
Add2FA(),
|
||||
}
|
||||
|
||||
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
|
||||
|
||||
@@ -270,3 +270,182 @@ func (s *Service) sendEmail(toEmail, subject, htmlContent string) error {
|
||||
return client.Quit()
|
||||
}
|
||||
}
|
||||
|
||||
// SendTestEmail sends a test email to verify email configuration
|
||||
func (s *Service) SendTestEmail(toEmail, subject, message string) error {
|
||||
if !s.Config.Email.Enabled {
|
||||
return fmt.Errorf("email service is disabled")
|
||||
}
|
||||
|
||||
// Use default subject if not provided
|
||||
if subject == "" {
|
||||
subject = "Test Email from GoMFT"
|
||||
}
|
||||
|
||||
// Use default message if not provided
|
||||
if message == "" {
|
||||
message = "This is a test email from GoMFT to verify the email configuration is working correctly."
|
||||
}
|
||||
|
||||
// Create email data for template
|
||||
data := map[string]interface{}{
|
||||
"Subject": subject,
|
||||
"Message": message,
|
||||
"AppName": "GoMFT",
|
||||
"Year": time.Now().Year(),
|
||||
"SMTPServer": s.Config.Email.Host,
|
||||
"SMTPPort": s.Config.Email.Port,
|
||||
"FromEmail": s.Config.Email.FromEmail,
|
||||
"CurrentTime": time.Now().Format(time.RFC1123Z),
|
||||
}
|
||||
|
||||
// Generate email content
|
||||
htmlContent, err := s.generateTestEmailHTML(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send the email
|
||||
return s.sendEmail(toEmail, subject, htmlContent)
|
||||
}
|
||||
|
||||
// generateTestEmailHTML generates the HTML content for test emails
|
||||
func (s *Service) generateTestEmailHTML(data map[string]interface{}) (string, error) {
|
||||
// HTML template for test email
|
||||
tmpl, err := template.New("testEmail").Parse(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Subject}}</title>
|
||||
<style>
|
||||
/* Base styles */
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f9fafb;
|
||||
color: #374151;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.logo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin: 0 auto 15px;
|
||||
background-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.logo-icon {
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
h1 {
|
||||
color: #111827;
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
.content {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 15px;
|
||||
color: #4b5563;
|
||||
}
|
||||
.info-box {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 6px;
|
||||
color: #4b5563;
|
||||
}
|
||||
.info-item {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.info-label {
|
||||
font-weight: bold;
|
||||
width: 140px;
|
||||
}
|
||||
.note {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-top: 30px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
padding: 20px 0;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<div class="logo-icon">G</div>
|
||||
</div>
|
||||
<h1>{{.Subject}}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>{{.Message}}</p>
|
||||
|
||||
<div class="info-box">
|
||||
<div class="info-item">
|
||||
<div class="info-label">SMTP Server:</div>
|
||||
<div>{{.SMTPServer}}:{{.SMTPPort}}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">From:</div>
|
||||
<div>{{.FromEmail}}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Sent:</div>
|
||||
<div>{{.CurrentTime}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<p>This is a test email sent from the GoMFT admin interface. If you've received this email, your email configuration is working correctly.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {{.Year}} {{.AppName}}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result bytes.Buffer
|
||||
if err := tmpl.Execute(&result, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
@@ -173,6 +173,11 @@ func NewLogger() *Logger {
|
||||
filepath.Join(logsDir, "scheduler.log"), maxSize, maxBackups, maxAge, compress, logLevel.String())
|
||||
}
|
||||
|
||||
if logLevel >= LogLevelDebug {
|
||||
logger.Debug.Printf("Log rotation details: file=%s, maxSize=%dMB, maxBackups=%d, maxAge=%d days, compress=%v",
|
||||
filepath.Join(logsDir, "scheduler.log"), maxSize, maxBackups, maxAge, compress)
|
||||
}
|
||||
|
||||
return logger
|
||||
}
|
||||
|
||||
@@ -258,6 +263,8 @@ func (s *Scheduler) loadJobs() {
|
||||
}
|
||||
|
||||
func (s *Scheduler) ScheduleJob(job *db.Job) error {
|
||||
s.log.LogDebug("Attempting to schedule job ID %d: %+v", job.ID, job)
|
||||
|
||||
s.log.LogInfo("Scheduling job %d: %s with schedule %s", job.ID, job.Name, job.Schedule)
|
||||
|
||||
// Remove existing job if it exists
|
||||
@@ -279,6 +286,8 @@ func (s *Scheduler) ScheduleJob(job *db.Job) error {
|
||||
schedule = "0 " + schedule
|
||||
}
|
||||
|
||||
s.log.LogDebug("Converted schedule from '%s' to '%s'", job.Schedule, schedule)
|
||||
|
||||
// Validate cron expression
|
||||
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
_, err := parser.Parse(schedule)
|
||||
@@ -286,6 +295,8 @@ func (s *Scheduler) ScheduleJob(job *db.Job) error {
|
||||
return fmt.Errorf("invalid cron expression '%s': %w", job.Schedule, err)
|
||||
}
|
||||
|
||||
s.log.LogDebug("Validated cron expression '%s' for job %d", schedule, job.ID)
|
||||
|
||||
// Schedule the job
|
||||
entryID, err := s.cron.AddFunc(job.Schedule, func() {
|
||||
s.executeJob(job.ID)
|
||||
@@ -296,6 +307,8 @@ func (s *Scheduler) ScheduleJob(job *db.Job) error {
|
||||
return err
|
||||
}
|
||||
|
||||
s.log.LogDebug("Scheduled job %d with cron entry ID %d", job.ID, entryID)
|
||||
|
||||
// Store mapping of job ID to cron entry ID
|
||||
s.jobMutex.Lock()
|
||||
s.jobs[job.ID] = entryID
|
||||
@@ -313,6 +326,9 @@ func (s *Scheduler) ScheduleJob(job *db.Job) error {
|
||||
}
|
||||
|
||||
func (s *Scheduler) executeJob(jobID uint) {
|
||||
s.log.LogDebug("Entering executeJob for job ID %d", jobID)
|
||||
defer s.log.LogDebug("Exiting executeJob for job ID %d", jobID)
|
||||
|
||||
s.log.LogInfo("Starting execution of job %d", jobID)
|
||||
|
||||
// Get job details
|
||||
@@ -322,6 +338,8 @@ func (s *Scheduler) executeJob(jobID uint) {
|
||||
return
|
||||
}
|
||||
|
||||
s.log.LogDebug("Loaded job details: %+v", job)
|
||||
|
||||
// Get all configurations associated with this job
|
||||
configs, err := s.db.GetConfigsForJob(jobID)
|
||||
if err != nil {
|
||||
@@ -329,12 +347,45 @@ func (s *Scheduler) executeJob(jobID uint) {
|
||||
return
|
||||
}
|
||||
|
||||
s.log.LogDebug("Loaded %d configurations for job %d", len(configs), jobID)
|
||||
|
||||
if len(configs) == 0 {
|
||||
s.log.LogError("Error: job %d has no associated configurations", jobID)
|
||||
return
|
||||
}
|
||||
|
||||
s.log.LogInfo("Loaded job %d with %d configurations", jobID, len(configs))
|
||||
// Get the ordered config IDs from the job
|
||||
orderedConfigIDs := job.GetConfigIDsList()
|
||||
s.log.LogDebug("Ordered config IDs for job %d: %v", jobID, orderedConfigIDs)
|
||||
|
||||
// Create a map of configs for easy lookup
|
||||
configMap := make(map[uint]db.TransferConfig)
|
||||
for _, config := range configs {
|
||||
configMap[config.ID] = config
|
||||
}
|
||||
|
||||
// Process configurations in the specified order
|
||||
var orderedConfigs []db.TransferConfig
|
||||
|
||||
// First, add configs in the order specified in the job's ConfigIDs
|
||||
for _, configID := range orderedConfigIDs {
|
||||
if config, exists := configMap[configID]; exists {
|
||||
orderedConfigs = append(orderedConfigs, config)
|
||||
delete(configMap, configID) // Remove from map to avoid duplicates
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining configs not in the ordered list (shouldn't happen, but just in case)
|
||||
for _, config := range configMap {
|
||||
orderedConfigs = append(orderedConfigs, config)
|
||||
}
|
||||
|
||||
s.log.LogInfo("Processing job %d with %d configurations in specified order", jobID, len(orderedConfigs))
|
||||
|
||||
// Log the order of execution
|
||||
for i, config := range orderedConfigs {
|
||||
s.log.LogDebug("Execution order %d/%d: Config ID %d (%s)", i+1, len(orderedConfigs), config.ID, config.Name)
|
||||
}
|
||||
|
||||
// Update job last run time
|
||||
startTime := time.Now()
|
||||
@@ -343,9 +394,9 @@ func (s *Scheduler) executeJob(jobID uint) {
|
||||
s.log.LogError("Error updating job last run time for job %d: %v", jobID, err)
|
||||
}
|
||||
|
||||
// Process each configuration
|
||||
for i, config := range configs {
|
||||
s.processConfiguration(&job, &config, i+1, len(configs))
|
||||
// Process each configuration in the specified order
|
||||
for i, config := range orderedConfigs {
|
||||
s.processConfiguration(&job, &config, i+1, len(orderedConfigs))
|
||||
}
|
||||
|
||||
// Update next run time after execution
|
||||
@@ -366,6 +417,8 @@ func (s *Scheduler) executeJob(jobID uint) {
|
||||
|
||||
// processConfiguration processes a single configuration for a job
|
||||
func (s *Scheduler) processConfiguration(job *db.Job, config *db.TransferConfig, index int, totalConfigs int) {
|
||||
s.log.LogDebug("Processing configuration %d: %+v", config.ID, config)
|
||||
|
||||
s.log.LogInfo("Processing configuration %d (%d/%d) for job %d: source=%s:%s, dest=%s:%s",
|
||||
config.ID,
|
||||
index,
|
||||
@@ -392,12 +445,16 @@ func (s *Scheduler) processConfiguration(job *db.Job, config *db.TransferConfig,
|
||||
return
|
||||
}
|
||||
|
||||
s.log.LogDebug("Creating job history record: %+v", history)
|
||||
|
||||
// Execute the configuration transfer
|
||||
s.executeConfigTransfer(*job, *config, history)
|
||||
}
|
||||
|
||||
// executeConfigTransfer performs the actual file transfer for a single configuration
|
||||
func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig, history *db.JobHistory) {
|
||||
s.log.LogDebug("Starting transfer for config %d with params: %+v", config.ID, config)
|
||||
|
||||
// Track files already processed in this job execution to prevent duplicates
|
||||
processedFiles := make(map[string]bool)
|
||||
|
||||
@@ -447,7 +504,7 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
|
||||
listArgs = append(listArgs, sourceListPath)
|
||||
|
||||
// Execute lsjson command
|
||||
s.log.LogInfo("Listing files with metadata for job %d, config %d: rclone %s", job.ID, config.ID, strings.Join(listArgs, " "))
|
||||
s.log.LogDebug("Full lsjson command: %s %v", os.Getenv("RCLONE_PATH"), listArgs)
|
||||
rclonePath := os.Getenv("RCLONE_PATH")
|
||||
if rclonePath == "" {
|
||||
rclonePath = "rclone"
|
||||
@@ -455,6 +512,19 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
|
||||
listCmd := exec.Command(rclonePath, listArgs...)
|
||||
listOutput, listErr := listCmd.CombinedOutput()
|
||||
|
||||
// Add debug logging of raw output
|
||||
if listErr == nil {
|
||||
s.log.LogDebug("Raw lsjson output for job %d config %d:\n%s",
|
||||
job.ID,
|
||||
config.ID,
|
||||
string(listOutput))
|
||||
} else {
|
||||
s.log.LogDebug("Raw lsjson output (error case) for job %d config %d:\n%s",
|
||||
job.ID,
|
||||
config.ID,
|
||||
string(listOutput))
|
||||
}
|
||||
|
||||
if listErr != nil {
|
||||
s.log.LogError("Error listing files for job %d, config %d: %v", job.ID, config.ID, listErr)
|
||||
// s.log.Debug.Printf("Output: %s", string(listOutput))
|
||||
@@ -550,7 +620,7 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
|
||||
concurrencySemaphore := make(chan struct{}, maxConcurrent)
|
||||
|
||||
// Process each file individually
|
||||
for _, fileEntry := range files {
|
||||
for i, fileEntry := range files {
|
||||
fileName, ok := fileEntry["Path"].(string)
|
||||
if !ok || fileName == "" {
|
||||
continue
|
||||
@@ -666,7 +736,8 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
|
||||
currentModTime := modTime
|
||||
|
||||
// Log the file information that will be processed
|
||||
s.log.LogDebug("Processing file: %s, size: %d, hash: %s", currentFileName, currentFileSize, currentFileHash)
|
||||
s.log.LogDebug("Processing file %d/%d: %s (Size: %d, Hash: %s)",
|
||||
i+1, len(files), currentFileName, currentFileSize, currentFileHash)
|
||||
|
||||
// Start goroutine for concurrent processing
|
||||
go func() {
|
||||
@@ -740,13 +811,8 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig,
|
||||
transferArgs = append(transferArgs, sourcePath, destPath)
|
||||
|
||||
// Execute transfer for this file
|
||||
s.log.LogInfo("Executing rclone transfer command for job %d, config %d, file %s: rclone %s",
|
||||
job.ID, config.ID, currentFileName, strings.Join(transferArgs, " "))
|
||||
// Get the rclone path from the environment variable or use the default path
|
||||
rclonePath := os.Getenv("RCLONE_PATH")
|
||||
if rclonePath == "" {
|
||||
rclonePath = "rclone"
|
||||
}
|
||||
s.log.LogDebug("Full transfer command: %s %v", rclonePath, transferArgs)
|
||||
s.log.LogDebug("Environment: RCLONE_PATH=%s", os.Getenv("RCLONE_PATH"))
|
||||
cmd := exec.Command(rclonePath, transferArgs...)
|
||||
fileOutput, fileErr := cmd.CombinedOutput()
|
||||
|
||||
@@ -1087,6 +1153,8 @@ func (s *Scheduler) sendWebhookNotification(job *db.Job, history *db.JobHistory,
|
||||
return
|
||||
}
|
||||
|
||||
s.log.LogDebug("Webhook payload: %s", string(jsonPayload))
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequest("POST", job.WebhookURL, bytes.NewBuffer(jsonPayload))
|
||||
if err != nil {
|
||||
@@ -1116,6 +1184,8 @@ func (s *Scheduler) sendWebhookNotification(job *db.Job, history *db.JobHistory,
|
||||
}
|
||||
}
|
||||
|
||||
s.log.LogDebug("Webhook headers: %+v", req.Header)
|
||||
|
||||
// Send the request with a timeout
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
|
||||
@@ -63,17 +63,28 @@ func (h *Handlers) HandleAdminTools(c *gin.Context) {
|
||||
data.TotalUsers = int(totalUsers)
|
||||
}
|
||||
|
||||
// Get last backup time and backup count
|
||||
data.LastBackupTime, data.BackupCount = h.getBackupInfo()
|
||||
// Get backup info (last backup time and count)
|
||||
lastBackup, backupCount := h.getBackupInfo()
|
||||
data.LastBackupTime = lastBackup
|
||||
data.BackupCount = backupCount
|
||||
|
||||
// Get list of backup files
|
||||
data.BackupFiles = h.getBackupFiles()
|
||||
|
||||
// Check for maintenance issues
|
||||
// Get maintenance message if any
|
||||
data.MaintenanceMessage = h.checkMaintenanceIssues()
|
||||
|
||||
// Render the admin tools page
|
||||
components.AdminTools(components.CreateTemplateContext(c), data).Render(c, c.Writer)
|
||||
// Add SMTP server info if available
|
||||
if h.Email != nil && h.Email.Config != nil && h.Email.Config.Email.Host != "" {
|
||||
smtpServer := h.Email.Config.Email.Host
|
||||
if h.Email.Config.Email.Port != 0 {
|
||||
data.SmtpServer = fmt.Sprintf("%s:%d", smtpServer, h.Email.Config.Email.Port)
|
||||
} else {
|
||||
data.SmtpServer = smtpServer
|
||||
}
|
||||
}
|
||||
|
||||
components.AdminTools(c.Request.Context(), data).Render(c.Request.Context(), c.Writer)
|
||||
}
|
||||
|
||||
// HandleBackupDatabase handles the backup database request
|
||||
@@ -1368,3 +1379,43 @@ func (h *Handlers) HandleImportConfigsFromFile(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d configs imported successfully", imported)})
|
||||
}
|
||||
|
||||
// HandleTestEmail handles the POST /admin/test-email route
|
||||
func (h *Handlers) HandleTestEmail(c *gin.Context) {
|
||||
// Parse the form
|
||||
recipient := c.PostForm("recipient")
|
||||
subject := c.PostForm("subject")
|
||||
message := c.PostForm("message")
|
||||
|
||||
// Validate required fields
|
||||
if recipient == "" {
|
||||
components.EmailTestToast(false, "Recipient email is required").Render(c.Request.Context(), c.Writer)
|
||||
return
|
||||
}
|
||||
|
||||
// Get SMTP server info for display
|
||||
smtpServer := ""
|
||||
if h.Email != nil && h.Email.Config != nil && h.Email.Config.Email.Host != "" {
|
||||
smtpServer = h.Email.Config.Email.Host
|
||||
if h.Email.Config.Email.Port != 0 {
|
||||
smtpServer = fmt.Sprintf("%s:%d", smtpServer, h.Email.Config.Email.Port)
|
||||
}
|
||||
}
|
||||
|
||||
// Send the test email
|
||||
if h.Email == nil {
|
||||
components.EmailTestToast(false, "Email service is not configured").Render(c.Request.Context(), c.Writer)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.Email.SendTestEmail(recipient, subject, message)
|
||||
if err != nil {
|
||||
// Failed to send email
|
||||
components.EmailTestToast(false, fmt.Sprintf("Failed to send email: %v", err)).Render(c.Request.Context(), c.Writer)
|
||||
return
|
||||
}
|
||||
|
||||
// Email sent successfully
|
||||
successMsg := fmt.Sprintf("Test email sent successfully to %s", recipient)
|
||||
components.EmailTestToast(true, successMsg).Render(c.Request.Context(), c.Writer)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -56,11 +57,21 @@ func (h *Handlers) AuthMiddleware() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Safely extract claims with type assertions and defaults
|
||||
userID, _ := claims["user_id"].(float64)
|
||||
email, _ := claims["email"].(string)
|
||||
username, _ := claims["username"].(string)
|
||||
isAdmin, _ := claims["is_admin"].(bool)
|
||||
|
||||
// Set user information in the context
|
||||
c.Set("userID", uint(claims["user_id"].(float64)))
|
||||
c.Set("email", claims["email"].(string))
|
||||
c.Set("username", claims["username"].(string))
|
||||
c.Set("isAdmin", claims["is_admin"].(bool))
|
||||
c.Set("userID", uint(userID))
|
||||
if email != "" {
|
||||
c.Set("email", email)
|
||||
}
|
||||
if username != "" {
|
||||
c.Set("username", username)
|
||||
}
|
||||
c.Set("isAdmin", isAdmin)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
@@ -142,10 +153,11 @@ func (h *Handlers) APIAdminMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// GenerateJWT generates a JWT token for the given user
|
||||
func (h *Handlers) GenerateJWT(userID uint, username string, isAdmin bool) (string, error) {
|
||||
func (h *Handlers) GenerateJWT(userID uint, email string, isAdmin bool) (string, error) {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": userID,
|
||||
"username": username,
|
||||
"email": email,
|
||||
"username": strings.Split(email, "@")[0], // Use email prefix as username
|
||||
"is_admin": isAdmin,
|
||||
"exp": time.Now().Add(time.Hour * 24).Unix(),
|
||||
})
|
||||
@@ -243,24 +255,30 @@ func (h *Handlers) HandleLogin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT token with all necessary user information
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": user.ID,
|
||||
"email": user.Email,
|
||||
"username": strings.Split(user.Email, "@")[0], // Use email prefix as username
|
||||
"is_admin": user.IsAdmin,
|
||||
"exp": time.Now().Add(time.Hour * 24).Unix(),
|
||||
})
|
||||
// Check if 2FA is enabled
|
||||
if user.TwoFactorEnabled {
|
||||
// Store user ID temporarily for 2FA verification
|
||||
c.SetCookie("temp_user_id", fmt.Sprintf("%d", user.ID), 300, "/", "", false, true) // 5 minutes expiry
|
||||
|
||||
// Sign the token
|
||||
tokenString, err := token.SignedString([]byte(h.JWTSecret))
|
||||
// Redirect to 2FA verification page
|
||||
c.Redirect(http.StatusFound, "/login/verify")
|
||||
return
|
||||
}
|
||||
|
||||
// If 2FA is not enabled, proceed with normal login
|
||||
// Generate JWT token with all necessary user information
|
||||
isAdmin := false
|
||||
if user.IsAdmin != nil {
|
||||
isAdmin = *user.IsAdmin
|
||||
}
|
||||
token, err := h.GenerateJWT(user.ID, user.Email, isAdmin)
|
||||
if err != nil {
|
||||
components.Login(components.CreateTemplateContext(c), "Authentication error").Render(c, c.Writer)
|
||||
return
|
||||
}
|
||||
|
||||
// Set token in cookie
|
||||
c.SetCookie("jwt_token", tokenString, 86400, "/", "", false, true)
|
||||
c.SetCookie("jwt_token", token, 86400, "/", "", false, true)
|
||||
c.Redirect(http.StatusFound, "/dashboard")
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/starfleetcptn/gomft/components"
|
||||
@@ -141,6 +143,9 @@ func (h *Handlers) HandleEditJob(c *gin.Context) {
|
||||
func (h *Handlers) HandleCreateJob(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
// Debug logging
|
||||
log.Printf("HandleCreateJob: Form data received: %v", c.Request.PostForm)
|
||||
|
||||
// Parse form data
|
||||
var job db.Job
|
||||
if err := c.ShouldBind(&job); err != nil {
|
||||
@@ -148,6 +153,9 @@ func (h *Handlers) HandleCreateJob(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
log.Printf("HandleCreateJob: Job after binding: %+v", job)
|
||||
|
||||
// Get multiple config IDs from form
|
||||
configIDs := c.PostFormArray("config_ids[]")
|
||||
if len(configIDs) == 0 {
|
||||
@@ -155,35 +163,83 @@ func (h *Handlers) HandleCreateJob(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
log.Printf("HandleCreateJob: config_ids[]: %v", configIDs)
|
||||
|
||||
// Process config IDs
|
||||
var configIDsList []uint
|
||||
for _, configIDStr := range configIDs {
|
||||
configID, err := strconv.ParseUint(configIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Invalid configuration ID format")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify that the config exists and belongs to the user
|
||||
var config db.TransferConfig
|
||||
if err := h.DB.First(&config, configID).Error; err != nil {
|
||||
c.String(http.StatusBadRequest, "Invalid configuration selected")
|
||||
return
|
||||
}
|
||||
// Check if we have an explicit order specified
|
||||
configOrder := c.PostForm("config_order")
|
||||
log.Printf("HandleCreateJob: config_order: %s", configOrder)
|
||||
|
||||
// Check if the config belongs to the user
|
||||
if config.CreatedBy != userID {
|
||||
// Check if user is admin
|
||||
isAdmin, exists := c.Get("isAdmin")
|
||||
if !exists || isAdmin != true {
|
||||
c.String(http.StatusForbidden, "You do not have permission to use this configuration")
|
||||
if configOrder != "" {
|
||||
// Parse the ordered list
|
||||
orderStrings := strings.Split(configOrder, ",")
|
||||
log.Printf("HandleCreateJob: order strings: %v", orderStrings)
|
||||
|
||||
for _, configIDStr := range orderStrings {
|
||||
configID, err := strconv.ParseUint(configIDStr, 10, 32)
|
||||
if err != nil {
|
||||
log.Printf("HandleCreateJob: Error parsing config ID: %v", err)
|
||||
c.String(http.StatusBadRequest, "Invalid configuration ID format in order")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
configIDsList = append(configIDsList, uint(configID))
|
||||
// Verify that the config exists and belongs to the user
|
||||
var config db.TransferConfig
|
||||
if err := h.DB.First(&config, configID).Error; err != nil {
|
||||
log.Printf("HandleCreateJob: Invalid config ID: %d, error: %v", configID, err)
|
||||
c.String(http.StatusBadRequest, "Invalid configuration selected")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the config belongs to the user
|
||||
if config.CreatedBy != userID {
|
||||
// Check if user is admin
|
||||
isAdmin, exists := c.Get("isAdmin")
|
||||
if !exists || isAdmin != true {
|
||||
c.String(http.StatusForbidden, "You do not have permission to use this configuration")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
configIDsList = append(configIDsList, uint(configID))
|
||||
}
|
||||
} else {
|
||||
// Fall back to unordered config IDs
|
||||
log.Printf("HandleCreateJob: No config_order found, using checkbox order")
|
||||
for _, configIDStr := range configIDs {
|
||||
configID, err := strconv.ParseUint(configIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Invalid configuration ID format")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify that the config exists and belongs to the user
|
||||
var config db.TransferConfig
|
||||
if err := h.DB.First(&config, configID).Error; err != nil {
|
||||
c.String(http.StatusBadRequest, "Invalid configuration selected")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the config belongs to the user
|
||||
if config.CreatedBy != userID {
|
||||
// Check if user is admin
|
||||
isAdmin, exists := c.Get("isAdmin")
|
||||
if !exists || isAdmin != true {
|
||||
c.String(http.StatusForbidden, "You do not have permission to use this configuration")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
configIDsList = append(configIDsList, uint(configID))
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
log.Printf("HandleCreateJob: Final configIDsList: %v", configIDsList)
|
||||
|
||||
// Set the first config ID for backward compatibility
|
||||
if len(configIDsList) > 0 {
|
||||
job.ConfigID = configIDsList[0]
|
||||
@@ -214,6 +270,10 @@ func (h *Handlers) HandleCreateJob(c *gin.Context) {
|
||||
// Set the config IDs list
|
||||
job.SetConfigIDsList(configIDsList)
|
||||
|
||||
// Debug logging
|
||||
log.Printf("HandleCreateJob: Job after setting ConfigIDsList: %+v", job)
|
||||
log.Printf("HandleCreateJob: Job.ConfigIDs: %s", job.ConfigIDs)
|
||||
|
||||
// Set the boolean fields - handle both "on" and "true" values for checkboxes
|
||||
enabledVal := c.Request.FormValue("enabled")
|
||||
jobEnabledValue := enabledVal == "on" || enabledVal == "true"
|
||||
@@ -239,10 +299,13 @@ func (h *Handlers) HandleCreateJob(c *gin.Context) {
|
||||
|
||||
// Create the job
|
||||
if err := h.DB.CreateJob(&job); err != nil {
|
||||
log.Printf("HandleCreateJob: Error creating job: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Failed to create job")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("HandleCreateJob: Job successfully created with ID: %d", job.ID)
|
||||
|
||||
// Schedule the job with the scheduler
|
||||
if err := h.Scheduler.ScheduleJob(&job); err != nil {
|
||||
c.String(http.StatusInternalServerError, "Job created but scheduling failed: "+err.Error())
|
||||
@@ -257,8 +320,13 @@ func (h *Handlers) HandleUpdateJob(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
// Debug logging
|
||||
log.Printf("HandleUpdateJob: Updating job ID: %s", id)
|
||||
log.Printf("HandleUpdateJob: Form data received: %v", c.Request.PostForm)
|
||||
|
||||
var job db.Job
|
||||
if err := h.DB.First(&job, id).Error; err != nil {
|
||||
log.Printf("HandleUpdateJob: Job not found: %v", err)
|
||||
c.String(http.StatusNotFound, "Job not found")
|
||||
return
|
||||
}
|
||||
@@ -275,49 +343,102 @@ func (h *Handlers) HandleUpdateJob(c *gin.Context) {
|
||||
|
||||
// Get the old job values for comparison
|
||||
oldJob := job
|
||||
log.Printf("HandleUpdateJob: Original job: %+v", oldJob)
|
||||
log.Printf("HandleUpdateJob: Original job ConfigIDs: %s", oldJob.ConfigIDs)
|
||||
|
||||
// Parse form data
|
||||
if err := c.ShouldBind(&job); err != nil {
|
||||
log.Printf("HandleUpdateJob: Error binding form data: %v", err)
|
||||
c.String(http.StatusBadRequest, "Invalid form data")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("HandleUpdateJob: Job after binding: %+v", job)
|
||||
|
||||
// Get multiple config IDs from form
|
||||
configIDs := c.PostFormArray("config_ids[]")
|
||||
if len(configIDs) == 0 {
|
||||
log.Printf("HandleUpdateJob: No config_ids[] found in form data")
|
||||
c.String(http.StatusBadRequest, "At least one configuration must be selected")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("HandleUpdateJob: config_ids[]: %v", configIDs)
|
||||
|
||||
// Process config IDs
|
||||
var configIDsList []uint
|
||||
for _, configIDStr := range configIDs {
|
||||
configID, err := strconv.ParseUint(configIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Invalid configuration ID format")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify that the config exists
|
||||
var config db.TransferConfig
|
||||
if err := h.DB.First(&config, configID).Error; err != nil {
|
||||
c.String(http.StatusBadRequest, "Invalid configuration selected")
|
||||
return
|
||||
}
|
||||
// Check if we have an explicit order specified
|
||||
configOrder := c.PostForm("config_order")
|
||||
log.Printf("HandleUpdateJob: config_order: %s", configOrder)
|
||||
|
||||
// Check if the config belongs to the user
|
||||
if config.CreatedBy != userID {
|
||||
// Check if user is admin
|
||||
isAdmin, exists := c.Get("isAdmin")
|
||||
if !exists || isAdmin != true {
|
||||
c.String(http.StatusForbidden, "You do not have permission to use this configuration")
|
||||
if configOrder != "" {
|
||||
// Parse the ordered list
|
||||
orderStrings := strings.Split(configOrder, ",")
|
||||
log.Printf("HandleUpdateJob: order strings: %v", orderStrings)
|
||||
|
||||
for _, configIDStr := range orderStrings {
|
||||
configID, err := strconv.ParseUint(configIDStr, 10, 32)
|
||||
if err != nil {
|
||||
log.Printf("HandleUpdateJob: Error parsing config ID: %v", err)
|
||||
c.String(http.StatusBadRequest, "Invalid configuration ID format in order")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
configIDsList = append(configIDsList, uint(configID))
|
||||
// Verify that the config exists
|
||||
var config db.TransferConfig
|
||||
if err := h.DB.First(&config, configID).Error; err != nil {
|
||||
log.Printf("HandleUpdateJob: Invalid config ID: %d, error: %v", configID, err)
|
||||
c.String(http.StatusBadRequest, "Invalid configuration selected")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the config belongs to the user
|
||||
if config.CreatedBy != userID {
|
||||
// Check if user is admin
|
||||
isAdmin, exists := c.Get("isAdmin")
|
||||
if !exists || isAdmin != true {
|
||||
c.String(http.StatusForbidden, "You do not have permission to use this configuration")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
configIDsList = append(configIDsList, uint(configID))
|
||||
}
|
||||
} else {
|
||||
// Fall back to unordered config IDs
|
||||
log.Printf("HandleUpdateJob: No config_order found, using checkbox order")
|
||||
for _, configIDStr := range configIDs {
|
||||
configID, err := strconv.ParseUint(configIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Invalid configuration ID format")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify that the config exists
|
||||
var config db.TransferConfig
|
||||
if err := h.DB.First(&config, configID).Error; err != nil {
|
||||
c.String(http.StatusBadRequest, "Invalid configuration selected")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the config belongs to the user
|
||||
if config.CreatedBy != userID {
|
||||
// Check if user is admin
|
||||
isAdmin, exists := c.Get("isAdmin")
|
||||
if !exists || isAdmin != true {
|
||||
c.String(http.StatusForbidden, "You do not have permission to use this configuration")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
configIDsList = append(configIDsList, uint(configID))
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
log.Printf("HandleUpdateJob: Final configIDsList: %v", configIDsList)
|
||||
|
||||
// Set the first config ID for backward compatibility
|
||||
if len(configIDsList) > 0 {
|
||||
job.ConfigID = configIDsList[0]
|
||||
@@ -332,6 +453,10 @@ func (h *Handlers) HandleUpdateJob(c *gin.Context) {
|
||||
// Set the config IDs list
|
||||
job.SetConfigIDsList(configIDsList)
|
||||
|
||||
// Debug logging
|
||||
log.Printf("HandleUpdateJob: Job after setting ConfigIDsList: %+v", job)
|
||||
log.Printf("HandleUpdateJob: Job.ConfigIDs: %s", job.ConfigIDs)
|
||||
|
||||
// Set the boolean fields - handle both "on" and "true" values for checkboxes
|
||||
enabledVal := c.Request.FormValue("enabled")
|
||||
jobEnabledValue := enabledVal == "on" || enabledVal == "true"
|
||||
@@ -357,10 +482,13 @@ func (h *Handlers) HandleUpdateJob(c *gin.Context) {
|
||||
job.Config = db.TransferConfig{}
|
||||
|
||||
if err := h.DB.UpdateJob(&job); err != nil {
|
||||
log.Printf("HandleUpdateJob: Error updating job: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Failed to update job")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("HandleUpdateJob: Job successfully updated")
|
||||
|
||||
// Reschedule the job with the scheduler
|
||||
if err := h.Scheduler.ScheduleJob(&job); err != nil {
|
||||
c.String(http.StatusInternalServerError, "Job updated but scheduling failed: "+err.Error())
|
||||
|
||||
@@ -10,6 +10,9 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
||||
router.GET("/", h.HandleHome)
|
||||
router.GET("/login", h.HandleLoginPage)
|
||||
router.POST("/login", h.HandleLogin)
|
||||
router.GET("/login/verify", h.Handle2FAVerifyPage)
|
||||
router.POST("/login/verify", h.Handle2FAVerify)
|
||||
router.GET("/login/backup-code", h.Handle2FABackupCodePage)
|
||||
router.GET("/forgot-password", h.HandleForgotPasswordPage)
|
||||
router.POST("/forgot-password", h.HandleForgotPassword)
|
||||
router.GET("/reset-password", h.HandleResetPasswordPage)
|
||||
@@ -22,6 +25,13 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
||||
// Password change route - only accessed from profile page
|
||||
authorized.POST("/change-password", h.HandleChangePassword)
|
||||
|
||||
// 2FA routes - under profile
|
||||
authorized.GET("/profile/2fa/setup", h.Handle2FASetup)
|
||||
authorized.POST("/profile/2fa/verify", h.Handle2FAVerifySetup)
|
||||
authorized.POST("/profile/2fa/disable", h.Handle2FADisable)
|
||||
authorized.GET("/profile/2fa/backup-codes", h.Handle2FABackupCodes)
|
||||
authorized.POST("/profile/2fa/regenerate-codes", h.Handle2FARegenerateCodes)
|
||||
|
||||
{
|
||||
authorized.GET("/dashboard", h.HandleDashboard)
|
||||
authorized.GET("/configs", h.HandleConfigs)
|
||||
@@ -100,6 +110,9 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
||||
admin.GET("/logs/refresh", h.HandleRefreshLogs)
|
||||
admin.GET("/logs/view/:fileName", h.HandleViewLog)
|
||||
admin.GET("/logs/download/:fileName", h.HandleDownloadLog)
|
||||
|
||||
// Email test route
|
||||
admin.POST("/test-email", h.HandleTestEmail)
|
||||
}
|
||||
|
||||
// API routes
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/starfleetcptn/gomft/components"
|
||||
"github.com/starfleetcptn/gomft/internal/auth"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Handle2FASetup handles the GET /profile/2fa/setup route
|
||||
func (h *Handlers) Handle2FASetup(c *gin.Context) {
|
||||
// Get user from context
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
var user struct {
|
||||
Email string
|
||||
TwoFactorEnabled bool
|
||||
}
|
||||
|
||||
if err := h.DB.Table("users").Select("email, two_factor_enabled").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to get user")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if 2FA is already enabled
|
||||
if user.TwoFactorEnabled {
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate TOTP secret and QR code URL
|
||||
secret, qrCodeURL, err := auth.GenerateTOTPSecret(user.Email)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to generate 2FA secret")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate backup codes - now returns both plain codes and hashed codes
|
||||
backupCodesPlain, backupCodesHashed, err := auth.GenerateBackupCodes()
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to generate backup codes")
|
||||
return
|
||||
}
|
||||
|
||||
// Store secret and backup codes in session temporarily
|
||||
// We store the plain secret in the cookie since it's temporary and will be encrypted before DB storage
|
||||
c.SetCookie("2fa_setup_secret", secret, 3600, "/", "", false, true)
|
||||
c.SetCookie("2fa_setup_backup_codes_hashed", backupCodesHashed, 3600, "/", "", false, true)
|
||||
|
||||
// Render setup page
|
||||
data := components.TwoFactorSetupData{
|
||||
QRCodeURL: qrCodeURL,
|
||||
Secret: secret,
|
||||
BackupCodes: backupCodesPlain, // Show plain codes to the user
|
||||
ErrorMessage: "",
|
||||
}
|
||||
components.TwoFactorSetup(c.Request.Context(), data).Render(c, c.Writer)
|
||||
}
|
||||
|
||||
// Handle2FAVerifySetup handles the POST /profile/2fa/verify route
|
||||
func (h *Handlers) Handle2FAVerifySetup(c *gin.Context) {
|
||||
// Get user from context
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
var user struct {
|
||||
Email string
|
||||
}
|
||||
if err := h.DB.Table("users").Select("email").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to get user")
|
||||
return
|
||||
}
|
||||
|
||||
// Get secret from session
|
||||
secret, err := c.Cookie("2fa_setup_secret")
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Setup session expired")
|
||||
return
|
||||
}
|
||||
|
||||
// Get backup codes from session - now using the hashed version
|
||||
backupCodesHashed, err := c.Cookie("2fa_setup_backup_codes_hashed")
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Setup session expired")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the code
|
||||
code := c.PostForm("code")
|
||||
// For verification during setup, we use the plain secret since it's not yet encrypted
|
||||
if !totp.Validate(code, secret) {
|
||||
// Regenerate QR code URL using the existing secret
|
||||
qrCodeURL, err := auth.GenerateQRCodeURL(secret, user.Email)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to generate QR code")
|
||||
return
|
||||
}
|
||||
|
||||
// For display, we need to generate new plain-text codes
|
||||
// but we'll keep the same hashed codes for storage
|
||||
backupCodesPlain := []string{}
|
||||
if backupCodesHashed != "" {
|
||||
// Create placeholder codes since we can't recover the original codes
|
||||
// We'll use placeholder text that indicates these were already generated
|
||||
for i := 0; i < auth.BackupCodeCount; i++ {
|
||||
backupCodesPlain = append(backupCodesPlain, "[BACKUP CODE ALREADY GENERATED]")
|
||||
}
|
||||
}
|
||||
|
||||
data := components.TwoFactorSetupData{
|
||||
QRCodeURL: qrCodeURL,
|
||||
Secret: secret,
|
||||
BackupCodes: backupCodesPlain,
|
||||
ErrorMessage: "Invalid verification code. Please try again.",
|
||||
}
|
||||
components.TwoFactorSetup(c.Request.Context(), data).Render(c, c.Writer)
|
||||
return
|
||||
}
|
||||
|
||||
// Encrypt the secret before storing in database
|
||||
encryptedSecret, err := auth.EncryptTOTPSecret(secret)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to secure 2FA secret")
|
||||
return
|
||||
}
|
||||
|
||||
// Update user with 2FA settings
|
||||
if err := h.DB.Table("users").Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"two_factor_secret": encryptedSecret, // Store the encrypted secret
|
||||
"two_factor_enabled": true,
|
||||
"backup_codes": backupCodesHashed, // Store the hashed codes
|
||||
}).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to enable 2FA")
|
||||
return
|
||||
}
|
||||
|
||||
// Clear setup cookies
|
||||
c.SetCookie("2fa_setup_secret", "", -1, "/", "", false, true)
|
||||
c.SetCookie("2fa_setup_backup_codes_hashed", "", -1, "/", "", false, true)
|
||||
|
||||
// Redirect to profile with success message
|
||||
c.Redirect(http.StatusFound, "/profile?message=2FA+enabled+successfully")
|
||||
}
|
||||
|
||||
// Handle2FAVerifyPage handles the GET /login/verify route
|
||||
func (h *Handlers) Handle2FAVerifyPage(c *gin.Context) {
|
||||
// Check if we have a temporary user ID
|
||||
_, err := c.Cookie("temp_user_id")
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
// Render verification page
|
||||
data := components.TwoFactorVerifyData{
|
||||
ErrorMessage: "",
|
||||
}
|
||||
components.TwoFactorVerify(c.Request.Context(), data).Render(c, c.Writer)
|
||||
}
|
||||
|
||||
// Handle2FAVerify handles the POST /login/verify route
|
||||
func (h *Handlers) Handle2FAVerify(c *gin.Context) {
|
||||
// Get user ID from cookie
|
||||
tempUserID, err := c.Cookie("temp_user_id")
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse user ID
|
||||
var userID uint
|
||||
if _, err := fmt.Sscanf(tempUserID, "%d", &userID); err != nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
var user struct {
|
||||
TwoFactorSecret string
|
||||
BackupCodes string
|
||||
Email string
|
||||
IsAdmin *bool
|
||||
}
|
||||
if err := h.DB.Table("users").Select("two_factor_secret, backup_codes, email, is_admin").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
code := c.PostForm("code")
|
||||
|
||||
// First try TOTP code
|
||||
if auth.ValidateTOTPCode(user.TwoFactorSecret, code) {
|
||||
// Generate new JWT token and set cookie
|
||||
isAdmin := false
|
||||
if user.IsAdmin != nil {
|
||||
isAdmin = *user.IsAdmin
|
||||
}
|
||||
token, err := h.GenerateJWT(userID, user.Email, isAdmin)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to generate token")
|
||||
return
|
||||
}
|
||||
c.SetCookie("jwt_token", token, 86400, "/", "", false, true)
|
||||
|
||||
// Clear temporary user ID cookie
|
||||
c.SetCookie("temp_user_id", "", -1, "/", "", false, true)
|
||||
|
||||
c.Redirect(http.StatusFound, "/dashboard")
|
||||
return
|
||||
}
|
||||
|
||||
// Then try backup code
|
||||
if auth.ValidateBackupCode(code, user.BackupCodes) {
|
||||
// Remove used backup code
|
||||
newBackupCodes := auth.RemoveBackupCode(code, user.BackupCodes)
|
||||
if err := h.DB.Table("users").Where("id = ?", userID).Update("backup_codes", newBackupCodes).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to update backup codes")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new JWT token and set cookie
|
||||
isAdmin := false
|
||||
if user.IsAdmin != nil {
|
||||
isAdmin = *user.IsAdmin
|
||||
}
|
||||
token, err := h.GenerateJWT(userID, user.Email, isAdmin)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to generate token")
|
||||
return
|
||||
}
|
||||
c.SetCookie("jwt_token", token, 86400, "/", "", false, true)
|
||||
|
||||
// Clear temporary user ID cookie
|
||||
c.SetCookie("temp_user_id", "", -1, "/", "", false, true)
|
||||
|
||||
c.Redirect(http.StatusFound, "/dashboard")
|
||||
return
|
||||
}
|
||||
|
||||
// If neither code is valid, show error
|
||||
data := components.TwoFactorVerifyData{
|
||||
ErrorMessage: "Invalid verification code. Please try again.",
|
||||
}
|
||||
components.TwoFactorVerify(c.Request.Context(), data).Render(c, c.Writer)
|
||||
}
|
||||
|
||||
// Handle2FADisable handles the POST /profile/2fa/disable route
|
||||
func (h *Handlers) Handle2FADisable(c *gin.Context) {
|
||||
// Get user ID from context
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
// Get current password from form
|
||||
currentPassword := c.PostForm("current_password")
|
||||
if currentPassword == "" {
|
||||
c.Data(http.StatusBadRequest, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||
<span class="block sm:inline">Current password is required</span>
|
||||
</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
var user struct {
|
||||
PasswordHash string
|
||||
TwoFactorEnabled bool
|
||||
}
|
||||
if err := h.DB.Table("users").Select("password_hash, two_factor_enabled").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
c.Data(http.StatusInternalServerError, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||
<span class="block sm:inline">Failed to get user information</span>
|
||||
</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)); err != nil {
|
||||
c.Data(http.StatusBadRequest, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||
<span class="block sm:inline">Current password is incorrect</span>
|
||||
</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if 2FA is already disabled
|
||||
if !user.TwoFactorEnabled {
|
||||
c.Data(http.StatusBadRequest, "text/html", []byte(`<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded mb-4" role="alert">
|
||||
<span class="block sm:inline">Two-factor authentication is already disabled</span>
|
||||
</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
// Disable 2FA
|
||||
if err := h.DB.Table("users").Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"two_factor_enabled": false,
|
||||
"two_factor_secret": nil,
|
||||
"backup_codes": nil,
|
||||
}).Error; err != nil {
|
||||
c.Data(http.StatusInternalServerError, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
|
||||
<span class="block sm:inline">Failed to disable two-factor authentication</span>
|
||||
</div>`))
|
||||
return
|
||||
}
|
||||
|
||||
// Return success message
|
||||
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4" role="alert">
|
||||
<span class="block sm:inline">Two-factor authentication has been disabled</span>
|
||||
<script>
|
||||
setTimeout(function() {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
</script>
|
||||
</div>`))
|
||||
}
|
||||
|
||||
// Handle2FABackupCodes handles the GET /profile/2fa/backup-codes route
|
||||
func (h *Handlers) Handle2FABackupCodes(c *gin.Context) {
|
||||
// Get user from context
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
var user struct {
|
||||
BackupCodes string
|
||||
TwoFactorEnabled bool
|
||||
Email string // We need email to generate new backup codes display
|
||||
}
|
||||
|
||||
if err := h.DB.Table("users").Select("backup_codes, two_factor_enabled, email").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to get user")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if 2FA is enabled
|
||||
if !user.TwoFactorEnabled {
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
return
|
||||
}
|
||||
|
||||
// If user just generated new codes, we need to show them
|
||||
// We can't derive the plain codes from the hashed ones, so we'll generate new ones for display
|
||||
message := c.Query("message")
|
||||
successMessage := ""
|
||||
if message != "" {
|
||||
successMessage = message
|
||||
}
|
||||
|
||||
// For backup codes display, we need to check where we are in the flow
|
||||
var backupCodes []string
|
||||
|
||||
// If the user just regenerated codes or they're viewing for the first time
|
||||
// we need to generate new codes to display, as we can't recover the hashed ones
|
||||
// We'll generate new temporary codes for display only, keeping the hashed ones in the database
|
||||
if strings.Contains(successMessage, "New backup codes generated") {
|
||||
// Generate new codes to display
|
||||
newCodes, _, err := auth.GenerateBackupCodes()
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to generate backup codes for display")
|
||||
return
|
||||
}
|
||||
backupCodes = newCodes
|
||||
|
||||
// Add a special warning about these being the only time they'll see these codes
|
||||
successMessage = "New backup codes generated successfully. IMPORTANT: Save these codes now. You won't be able to see them again!"
|
||||
} else {
|
||||
// If they're just viewing an existing page, show a message explaining
|
||||
// that backup codes are securely stored and they need to regenerate to view new ones
|
||||
backupCodes = []string{}
|
||||
if user.BackupCodes != "" {
|
||||
// Count how many backup codes the user has by counting commas+1
|
||||
codeCount := 1
|
||||
if user.BackupCodes != "" {
|
||||
codeCount = strings.Count(user.BackupCodes, ",") + 1
|
||||
}
|
||||
|
||||
// Show a placeholder message for existing codes
|
||||
for i := 0; i < codeCount; i++ {
|
||||
backupCodes = append(backupCodes, "[REDACTED FOR SECURITY]")
|
||||
}
|
||||
|
||||
// Set a message explaining why codes are hidden
|
||||
successMessage = "For security, backup codes are not displayed after initial generation. Generate new codes to replace existing ones."
|
||||
}
|
||||
}
|
||||
|
||||
// Render backup codes page
|
||||
data := components.TwoFactorBackupCodesData{
|
||||
BackupCodes: backupCodes,
|
||||
SuccessMessage: successMessage,
|
||||
}
|
||||
components.TwoFactorBackupCodes(c.Request.Context(), data).Render(c, c.Writer)
|
||||
}
|
||||
|
||||
// Handle2FARegenerateCodes handles the POST /profile/2fa/regenerate-codes route
|
||||
func (h *Handlers) Handle2FARegenerateCodes(c *gin.Context) {
|
||||
// Get user from context
|
||||
userID := c.GetUint("userID")
|
||||
|
||||
var user struct {
|
||||
TwoFactorEnabled bool
|
||||
}
|
||||
|
||||
if err := h.DB.Table("users").Select("two_factor_enabled").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to get user")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if 2FA is enabled
|
||||
if !user.TwoFactorEnabled {
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new backup codes - we only need the hashed version for storage
|
||||
_, backupCodesHashed, err := auth.GenerateBackupCodes()
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to generate backup codes")
|
||||
return
|
||||
}
|
||||
|
||||
// Store new backup codes (hashed version)
|
||||
if err := h.DB.Table("users").Where("id = ?", userID).Update("backup_codes", backupCodesHashed).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to update backup codes")
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect back to backup codes page with success message
|
||||
c.Redirect(http.StatusFound, "/profile/2fa/backup-codes?message=New backup codes generated successfully")
|
||||
}
|
||||
|
||||
// Handle2FABackupCodePage handles the GET /login/backup-code route
|
||||
func (h *Handlers) Handle2FABackupCodePage(c *gin.Context) {
|
||||
// Check if we have a temporary user ID
|
||||
_, err := c.Cookie("temp_user_id")
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
// Render backup code verification page
|
||||
data := components.BackupCodeVerifyData{
|
||||
ErrorMessage: "",
|
||||
}
|
||||
components.TwoFactorBackupVerify(c.Request.Context(), data).Render(c, c.Writer)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
// "github.com/starfleetcptn/gomft/internal/api"
|
||||
"github.com/starfleetcptn/gomft/components"
|
||||
"github.com/starfleetcptn/gomft/internal/config"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
"github.com/starfleetcptn/gomft/internal/scheduler"
|
||||
@@ -27,7 +28,7 @@ func main() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
log.Printf("Starting GoMFT server...")
|
||||
log.Printf("Starting GoMFT server version %s...", components.AppVersion)
|
||||
|
||||
// Initialize configuration
|
||||
cfg, err := config.Load()
|
||||
|
||||
Reference in New Issue
Block a user