This commit is contained in:
Marco Pedone 2025-10-23 15:16:42 +02:00
parent 6a5820dda1
commit 5ea42bb24b
21 changed files with 160 additions and 122 deletions

View file

@ -36,6 +36,9 @@ type Config struct {
RootUser string
Password string
}
GOFS struct {
Endpoint string
}
}
Server struct {
@ -79,7 +82,8 @@ func Load() *Config {
Cfg.Storage.TempLimit = getEnvAsInt("TEMP_STORAGE_LIMIT", 5)
strategy := getEnv("STORAGE_STRATEGY", "fs")
if strategy == "s3" || strategy == "minio" {
switch strategy {
case "s3", "minio":
Cfg.Storage.Minio.URL = getEnv("MINIO_URL", "")
Cfg.Storage.Minio.RootUser = getEnv("MINIO_ROOT_USER", "")
Cfg.Storage.Minio.Password = getEnv("MINIO_ROOT_PASSWORD", "")
@ -90,7 +94,10 @@ func Load() *Config {
} else {
Cfg.Storage.Strategy = typesdefs.S3
}
} else {
case "gofs":
Cfg.Storage.GOFS.Endpoint = getEnv("GOFS_ENDPOINT", "localhost:8080")
Cfg.Storage.Strategy = typesdefs.GOFS
default:
Cfg.Storage.Strategy = typesdefs.FS
}

View file

@ -9,6 +9,8 @@ import (
"path/filepath"
)
var _ StorageHandler = (*Fs_Handler)(nil)
type Fs_Handler struct {
}

View file

@ -0,0 +1,93 @@
package storagehandlers
import (
"backend/config"
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
)
var _ StorageHandler = (*GOFS_Handler)(nil)
type GOFS_Handler struct {
Endpoint string
}
func NewGoFsHandler() *GOFS_Handler {
return &GOFS_Handler{
Endpoint: config.Cfg.Storage.GOFS.Endpoint,
}
}
func (g *GOFS_Handler) Init() error {
resp, err := http.Get(g.Endpoint + "/health")
if err != nil {
return fmt.Errorf("storage server unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("storage server unhealthy: %s", resp.Status)
}
return nil
}
func (g *GOFS_Handler) Add(file *multipart.FileHeader, fileId string) error {
f, err := file.Open()
if err != nil {
return err
}
defer f.Close()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", file.Filename)
if err != nil {
return err
}
if _, err := io.Copy(part, f); err != nil {
return err
}
_ = writer.WriteField("fileId", fileId)
writer.Close()
req, err := http.NewRequest("POST", g.Endpoint+"/upload", body)
if err != nil {
return err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("upload failed: %s", resp.Status)
}
return nil
}
func (g *GOFS_Handler) Delete(fileId string) error {
req, err := http.NewRequest("DELETE", g.Endpoint+"/delete/"+fileId, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("delete failed: %s", resp.Status)
}
return nil
}
func (g *GOFS_Handler) Get(filename string) (string, error) {
panic("unimplemented")
}
func (g *GOFS_Handler) List() ([]string, error) {
panic("unimplemented")
}

View file

@ -16,6 +16,7 @@ var (
useSSL = false
bucketName = "storage"
)
var _ StorageHandler = (*S3_Handler)(nil)
type S3_Handler struct {
minioClient *minio.Client

View file

@ -39,6 +39,8 @@ func NewStorageHandler(strategy typesdefs.StorageStrategy) (StorageHandler, erro
return nil, err
}
return h, nil
case typesdefs.GOFS:
return nil, nil
default:
return nil, errors.New("invalid storage strategy")
}

View file

@ -3,6 +3,7 @@ package typesdefs
type StorageStrategy string
const (
FS StorageStrategy = "fs"
S3 StorageStrategy = "s3"
FS StorageStrategy = "fs"
S3 StorageStrategy = "s3"
GOFS StorageStrategy = "gofs"
)

View file

@ -65,6 +65,7 @@ ENV TILES_URL="localhost:6379"
ENV SKEBBY_USER="mock"
ENV SKEBBY_PASS="mock"
ENV REVALIDATION_SECRET="mock"
ENV STORAGE_TOKEN="mock"
RUN SKIP_ENV_VALIDATION=1 npm run build
@ -131,6 +132,8 @@ ENV SKEBBY_USER=$SKEBBY_USER
ARG SKEBBY_PASS
ENV SKEBBY_PASS=$SKEBBY_PASS
ENV REVALIDATION_SECRET="SWKpgaaLfsyeqV1eOT0WG7TUFBewir8kJXure3O37ki8Lt4Z4IiEgBW4zPHDdM5c"
ARG STORAGE_TOKEN
ENV STORAGE_TOKEN=$STORAGE_TOKEN
RUN addgroup -g 1001 -S nodejs

View file

@ -3,9 +3,9 @@
* This is especially useful for Docker builds.
*/
import { env } from "node:process";
import { fileURLToPath } from "node:url";
import type { NextConfig } from "next";
import { env } from "~/env";
async function createNextConfig(): Promise<NextConfig> {
const { createJiti } = await import("jiti");
@ -60,8 +60,8 @@ async function createNextConfig(): Promise<NextConfig> {
rewrites: async () => {
return [
{
source: "/storage/:slug*",
destination: "http://localhost:8080/:slug*",
source: "/storage-api/:slug*",
destination: `http://localhost:8080/:slug*?token=${env.STORAGE_TOKEN}`,
},
{
destination: env.NODE_ENV === "production" ? "/404" : "/api/panel",

View file

@ -28,6 +28,7 @@ export const env = createEnv({
SKEBBY_USER: z.string(),
SKEBBY_PASS: z.string(),
REVALIDATION_SECRET: z.string(),
STORAGE_TOKEN: z.string(),
},
client: {
NEXT_PUBLIC_BASE_URL: z.string(),

View file

@ -1,46 +0,0 @@
import { type NextRequest, NextResponse } from "next/server";
import { env } from "~/env";
export const apisMiddleware = async (req: NextRequest) => {
const path = req.nextUrl.pathname;
const rewrite_paths = ["/go-api/images/", "/go-api/storage/"];
if (!rewrite_paths.some((p) => path.startsWith(p))) {
return;
}
if (
path.startsWith("/go-api/images/") ||
path.startsWith("/go-api/storage/")
) {
// Handle image and storage requests
const newPath = path
.replace("/go-api/images/", "/images/")
.replace("/go-api/storage/", "/storage/");
const url = new URL(`${env.BACKENDSERVER_URL}${newPath}`, req.url);
const res = NextResponse.rewrite(url);
res.headers.set("Access-Control-Allow-Origin", env.NEXT_PUBLIC_BASE_URL);
res.headers.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS",
);
res.headers.set(
"Access-Control-Allow-Headers",
"Content-Type, Authorization",
);
return res;
// Fetch the content from the backend and serve it through the middleware
/*
const response = await fetch(url.toString(), {
headers: req.headers,
});
return new NextResponse(response.body, {
headers: response.headers,
status: response.status,
});*/
}
};

View file

@ -243,7 +243,7 @@ function FileManagementPage() {
<img
alt={file.originalName}
className="size-20"
src={`/storage/file/${file.id}`}
src={`/storage-api/file/${file.id}`}
/>
)}
<div className="flex-1">
@ -260,7 +260,7 @@ function FileManagementPage() {
<div className="flex space-x-2">
<a
className="rounded bg-blue-500 px-3 py-1 text-sm text-white"
href={`/storage/file/${file.id}`}
href={`/storage-api/file/${file.id}`}
>
Download
</a>

View file

@ -17,10 +17,10 @@ import { db } from "~/server/db";
import { RateLimiterHandler } from "~/server/services/ratelimiter";
import {
addUserStorageJunc,
deleteFile,
deleteHandler,
deleteUserStorageJunc,
getNewTokenHandler,
updateFile,
updateHandler,
} from "~/server/services/storage.service";
import { zStorageIndexId, zUserId } from "~/server/utils/zod_types";
@ -58,7 +58,7 @@ export const storageRouter = createTRPCRouter({
deleteStorage: protectedProcedure
.input(z.object({ storageId: zStorageIndexId }))
.mutation(async ({ input }) => {
return await deleteFile({
return await deleteHandler({
cb: async (fileId) => {
await db
.deleteFrom("storageindex")
@ -133,7 +133,7 @@ export const storageRouter = createTRPCRouter({
renameFile: adminProcedure
.input(z.object({ fileId: zStorageIndexId, name: z.string() }))
.mutation(async ({ input }) => {
return await updateFile({
return await updateHandler({
data: { filename: input.name },
db,
fileId: input.fileId,

View file

@ -8,7 +8,7 @@ import type { UsersId } from "~/schemas/public/Users";
import type { Querier } from "~/server/db";
import {
addUserStorageJunc,
deleteFile,
deleteHandler,
deleteUserStorageJunc,
} from "~/server/services/storage.service";
@ -98,7 +98,7 @@ export const deleteUserStorageHandler = async ({
return "no file";
}
await deleteFile({
await deleteHandler({
cb: async (fileId) => {
await db.transaction().execute(async (trx) => {
await deleteUserStorageJunc({

View file

@ -7,6 +7,7 @@ import type {
import type { TempTokensToken } from "~/schemas/public/TempTokens";
import type { UsersId } from "~/schemas/public/Users";
import type { Querier } from "~/server/db";
import { deleteFile } from "../storage";
export const getNewTokenHandler = async ({
db,
@ -43,7 +44,7 @@ const newToken = async ({ db }: { db: Querier }) => {
}
};
export const deleteFile = async ({
export const deleteHandler = async ({
db,
fileId,
cb,
@ -58,22 +59,8 @@ export const deleteFile = async ({
.select(["id", "ext"])
.where("id", "=", fileId)
.executeTakeFirstOrThrow();
await deleteFile(storage.id);
const authToken = (await getNewTokenHandler({ db }))[0];
if (!authToken) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Error while getting auth token",
});
}
const url = `${env.BACKENDSERVER_URL}/storage/delete/${storage.id}${storage.ext}?token=${authToken}`;
const response = await fetch(url, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("File remove failed");
}
if (cb) {
await cb(fileId);
}
@ -86,32 +73,7 @@ export const deleteFile = async ({
}
};
// export const deleteFiles = async ({
// db,
// refs,
// cb,
// }: {
// db: Querier;
// refs: { storageid: StorageindexId }[];
// cb?: (refs: { storageid: StorageindexId }[]) => Promise<void>;
// }) => {
// try {
// await Promise.all(
// refs.map((ref) => deleteFile({ db, fileId: ref.storageid })),
// );
// if (cb) {
// await cb(refs);
// }
// } catch (error) {
// console.error("Error deleting files:", error);
// throw new TRPCError({
// code: "INTERNAL_SERVER_ERROR",
// message: "Error while deleting files",
// });
// }
// };
export const updateFile = async ({
export const updateHandler = async ({
db,
fileId,
data,

View file

@ -15,9 +15,10 @@ export type FileMetadata = z.infer<typeof FileMetadataSchema>;
// Helper to fetch the list of all files
export async function fetchFiles(): Promise<FileMetadata[]> {
try {
const response = await fetch(`/storage/api/files`);
const response = await fetch(`/storage-api/api/files`);
if (!response.ok) {
throw new Error(`Failed to fetch files: ${response.statusText}`);
console.error("Failed to fetch file list:", response.statusText);
return [];
}
const parse = FileMetadataSchema.array().safeParse(await response.json());
@ -44,7 +45,7 @@ export async function uploadFile(
}
try {
const response = await fetch(`/storage/upload`, {
const response = await fetch(`/storage-api/upload`, {
method: "POST",
body: formData,
});
@ -66,17 +67,10 @@ export async function uploadFile(
// Helper to delete a file
export async function deleteFile(fileID: string): Promise<boolean> {
const formData = new URLSearchParams();
formData.append("id", fileID);
try {
// The Go server accepts POST with form data for delete
const response = await fetch(`/storage/delete`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: formData.toString(),
const response = await fetch(`/storage-api/delete/${fileID}`, {
method: "DELETE",
});
// Treat redirect as success

View file

@ -15,7 +15,6 @@ import type { AppRouter } from "~/server/api/root";
const getUrl = () => {
const base = (() => {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.APP_URL) return process.env.APP_URL;
return `http://localhost:${process.env.PORT ?? 3000}`;
})();

View file

@ -19,6 +19,7 @@ set -euo pipefail
: "${TAG:?TAG is required}"
: "${IMAGE_REGISTRY:?IMAGE_REGISTRY is required}"
: "${PREFIX:?PREFIX is required}"
: "${OWNER:?OWNER is required}"
: "${SERVICES:?SERVICES is required}"
: "${USERNAME:?USERNAME is required}"
@ -55,12 +56,12 @@ for svc in ${SERVICES}; do
;;
esac
IMAGE="${IMAGE_REGISTRY}/${OWNER}/infoalloggi-${svc}:${TAG}"
echo "Building ${svc} -> ${IMAGE} (context: ${ctx})"
IMAGE="${IMAGE_REGISTRY}/${OWNER}/${PREFIX}-${svc}:${TAG}"
echo "Building ${PREFIX}-${svc} -> ${IMAGE} (context: ${ctx})"
docker build --build-arg ENV_FILE="${ENV_FILE}" -t "${IMAGE}" -f "$ctx/$dockerfile" "$ctx"
echo "Pushing ${IMAGE}"
docker push "${IMAGE}"
done
echo "All done. Built and pushed services: ${SERVICES} with tag ${TAG} to ${IMAGE_REGISTRY}/${OWNER}/infoalloggi-<service>:${TAG}"
echo "All done. Built and pushed services: ${SERVICES} with tag ${TAG} to ${IMAGE_REGISTRY}/${OWNER}/${PREFIX}-<service>:${TAG}"

14
deploy_infoalloggi.sh Normal file
View file

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
# Preset for staging environment
export TAG="latest"
export IMAGE_REGISTRY="ghcr.io"
export OWNER="marcopedone"
export SERVICES="web backend db"
export USERNAME="marcopedone"
export TOKEN="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"
export ENV_FILE=".env.infoalloggi"
export PREFIX="infoalloggi"
./build_and_push_ghcr.sh

View file

@ -9,5 +9,6 @@ export SERVICES="web backend db"
export USERNAME="marcopedone"
export TOKEN="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"
export ENV_FILE=".env.marcopedone"
export PREFIX="marcopedone"
./build_and_push_ghcr.sh

View file

@ -150,6 +150,7 @@ services:
JWT_SECRET: ${JWT_SECRET}
SKEBBY_USER: ${SKEBBY_USER}
SKEBBY_PASS: ${SKEBBY_PASS}
STORAGE_TOKEN: ${STORAGE_TOKEN}
working_dir: /app
environment:
INTERNAL_BASE_URL: http://web:3000
@ -176,6 +177,7 @@ services:
JWT_SECRET: ${JWT_SECRET}
SKEBBY_USER: ${SKEBBY_USER}
SKEBBY_PASS: ${SKEBBY_PASS}
STORAGE_TOKEN: ${STORAGE_TOKEN}
networks:
- dokploy-network
ports:

View file

@ -1,6 +1,6 @@
services:
db:
image: "${IMAGE_REGISTRY:-ghcr.io}/${OWNER:-marcopedone}/infoalloggi-db:${TAG:-latest}"
image: "${IMAGE_REGISTRY:-ghcr.io}/${OWNER:-marcopedone}/${PREFIX:-infoalloggi}-db:${TAG:-latest}"
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
@ -78,7 +78,7 @@ services:
cpus: "0.5"
backend:
image: "${IMAGE_REGISTRY:-ghcr.io}/${OWNER:-marcopedone}/infoalloggi-backend:${TAG:-latest}"
image: "${IMAGE_REGISTRY:-ghcr.io}/${OWNER:-marcopedone}/${PREFIX:-infoalloggi}-backend:${TAG:-latest}"
networks:
- dokploy-network
expose:
@ -116,7 +116,7 @@ services:
condition: service_healthy
web:
image: "${IMAGE_REGISTRY:-ghcr.io}/${OWNER:-marcopedone}/infoalloggi-web:${TAG:-latest}"
image: "${IMAGE_REGISTRY:-ghcr.io}/${OWNER:-marcopedone}/${PREFIX:-infoalloggi}-web:${TAG:-latest}"
working_dir: /app
pull_policy: "always"
environment:
@ -145,6 +145,7 @@ services:
JWT_SECRET: ${JWT_SECRET}
SKEBBY_USER: ${SKEBBY_USER}
SKEBBY_PASS: ${SKEBBY_PASS}
STORAGE_TOKEN: ${STORAGE_TOKEN}
networks:
- dokploy-network
ports: