diff --git a/backend/prisma/seed/config.seed.ts b/backend/prisma/seed/config.seed.ts index 78b945d..dfd83ef 100644 --- a/backend/prisma/seed/config.seed.ts +++ b/backend/prisma/seed/config.seed.ts @@ -30,8 +30,8 @@ const configVariables: ConfigVariables = { secret: false, }, sessionDuration: { - type: "number", - defaultValue: "2160", + type: "timespan", + defaultValue: "3 months", secret: false, }, }, @@ -47,8 +47,8 @@ const configVariables: ConfigVariables = { secret: false, }, maxExpiration: { - type: "number", - defaultValue: "0", + type: "timespan", + defaultValue: "0 days", secret: false, }, shareIdLength: { diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index cc899b5..3492f92 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -306,11 +306,12 @@ export class AuthService { } async createRefreshToken(userId: string, idToken?: string) { + const sessionDuration = this.config.get("general.sessionDuration"); const { id, token } = await this.prisma.refreshToken.create({ data: { userId, expiresAt: moment() - .add(this.config.get("general.sessionDuration"), "hours") + .add(sessionDuration.value, sessionDuration.unit) .toDate(), oauthIDToken: idToken, }, @@ -341,14 +342,18 @@ export class AuthService { secure: isSecure, maxAge: 1000 * 60 * 60 * 24 * 30 * 3, // 3 months }); - if (refreshToken) + if (refreshToken) { + const now = moment(); + const sessionDuration = this.config.get("general.sessionDuration"); + const maxAge = moment(now).add(sessionDuration.value, sessionDuration.unit).diff(now); response.cookie("refresh_token", refreshToken, { path: "/api/auth/token", httpOnly: true, sameSite: "strict", secure: isSecure, - maxAge: 1000 * 60 * 60 * this.config.get("general.sessionDuration"), + maxAge, }); + } } /** diff --git a/backend/src/config/config.service.ts b/backend/src/config/config.service.ts index 5e70be6..be88ed6 100644 --- a/backend/src/config/config.service.ts +++ b/backend/src/config/config.service.ts @@ -7,6 +7,7 @@ import { import { Config } from "@prisma/client"; import { EventEmitter } from "events"; import { PrismaService } from "src/prisma/prisma.service"; +import { stringToTimespan } from "src/utils/date.util"; /** * ConfigService extends EventEmitter to allow listening for config updates, @@ -35,6 +36,8 @@ export class ConfigService extends EventEmitter { if (configVariable.type == "boolean") return value == "true"; if (configVariable.type == "string" || configVariable.type == "text") return value; + if (configVariable.type == "timespan") + return stringToTimespan(value); } async getByCategory(category: string) { @@ -94,7 +97,8 @@ export class ConfigService extends EventEmitter { } else if ( typeof value != configVariable.type && typeof value == "string" && - configVariable.type != "text" + configVariable.type != "text" && + configVariable.type != "timespan" ) { throw new BadRequestException( `Config variable must be of type ${configVariable.type}`, @@ -132,6 +136,7 @@ export class ConfigService extends EventEmitter { condition: (value: number) => value >= 0 && value <= 9, message: "Zip compression level must be between 0 and 9", }, + // TODO add validation for timespan type ]; const validation = validations.find((validation) => validation.key == key); diff --git a/backend/src/share/share.service.ts b/backend/src/share/share.service.ts index 8cfcbff..f13d8fa 100644 --- a/backend/src/share/share.service.ts +++ b/backend/src/share/share.service.ts @@ -56,12 +56,13 @@ export class ShareService { const expiresNever = moment(0).toDate() == parsedExpiration; + const maxExpiration = this.config.get("share.maxExpiration"); if ( - this.config.get("share.maxExpiration") !== 0 && + maxExpiration.value !== 0 && (expiresNever || parsedExpiration > moment() - .add(this.config.get("share.maxExpiration"), "hours") + .add(maxExpiration.value, maxExpiration.unit) .toDate()) ) { throw new BadRequestException( diff --git a/backend/src/utils/date.util.ts b/backend/src/utils/date.util.ts index ab56615..db50b13 100644 --- a/backend/src/utils/date.util.ts +++ b/backend/src/utils/date.util.ts @@ -10,3 +10,20 @@ export function parseRelativeDateToAbsolute(relativeDate: string) { ) .toDate(); } + +type Timespan = { + value: number; + unit: "minutes" | "hours" | "days" | "weeks" | "months" | "years"; +}; + +export function stringToTimespan(value: string): Timespan { + const [time, unit] = value.split(" "); + return { + value: parseInt(time), + unit: unit as Timespan["unit"], + }; +} + +export function timespanToString(timespan: Timespan) { + return `${timespan.value} ${timespan.unit}`; +} diff --git a/frontend/src/components/admin/configuration/AdminConfigInput.tsx b/frontend/src/components/admin/configuration/AdminConfigInput.tsx index 28a18f7..17aec4b 100644 --- a/frontend/src/components/admin/configuration/AdminConfigInput.tsx +++ b/frontend/src/components/admin/configuration/AdminConfigInput.tsx @@ -8,6 +8,8 @@ import { } from "@mantine/core"; import { useForm } from "@mantine/form"; import { AdminConfig, UpdateConfig } from "../../../types/config.type"; +import TimespanInput from "../../core/TimespanInput"; +import { stringToTimespan, timespanToString } from "../../../utils/date.util"; import FileSizeInput from "../../core/FileSizeInput"; const AdminConfigInput = ({ @@ -91,6 +93,13 @@ const AdminConfigInput = ({ /> )} + {configVariable.type == "timespan" && ( + onValueChange(configVariable, timespanToString(timespan))} + w={201} + /> + )} ); }; diff --git a/frontend/src/components/core/TimespanInput.tsx b/frontend/src/components/core/TimespanInput.tsx new file mode 100644 index 0000000..b1346ce --- /dev/null +++ b/frontend/src/components/core/TimespanInput.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { Timespan } from "../../types/timespan.type"; +import { NativeSelect, NumberInput } from "@mantine/core"; +import useTranslate from "../../hooks/useTranslate.hook"; + +const TimespanInput = ({ label, value, onChange, ...restProps }: { + label?: string, + value: Timespan, + onChange: (timespan: Timespan) => void, + [key: string]: any, +}) => { + const [unit, setUnit] = useState(value.unit); + const [inputValue, setInputValue] = useState(value.value); + const t = useTranslate(); + + const version = inputValue == 1 ? "singular" : "plural"; + const unitSelect = ( + { + const unit = event.currentTarget.value as Timespan["unit"]; + setUnit(unit); + onChange({ value: inputValue, unit }); + }} + /> + ); + + return ( + { + const inputVal = value || 0; + setInputValue(inputVal); + onChange({ value: inputVal, unit }); + }} + {...restProps} + /> + ); +}; + +export default TimespanInput; diff --git a/frontend/src/components/upload/modals/showCreateUploadModal.tsx b/frontend/src/components/upload/modals/showCreateUploadModal.tsx index f69921d..0b1e6e5 100644 --- a/frontend/src/components/upload/modals/showCreateUploadModal.tsx +++ b/frontend/src/components/upload/modals/showCreateUploadModal.tsx @@ -31,6 +31,7 @@ import { FileUpload } from "../../../types/File.type"; import { CreateShare } from "../../../types/share.type"; import { getExpirationPreview } from "../../../utils/date.util"; import toast from "../../../utils/toast.util"; +import { Timespan } from "../../../types/timespan.type"; const showCreateUploadModal = ( modals: ModalsContextProps, @@ -39,7 +40,7 @@ const showCreateUploadModal = ( isReverseShare: boolean; allowUnauthenticatedShares: boolean; enableEmailRecepients: boolean; - maxExpirationInHours: number; + maxExpiration: Timespan; shareIdLength: number; simplified: boolean; }, @@ -112,7 +113,7 @@ const CreateUploadModalBody = ({ isReverseShare: boolean; allowUnauthenticatedShares: boolean; enableEmailRecepients: boolean; - maxExpirationInHours: number; + maxExpiration: Timespan; shareIdLength: number; }; }) => { @@ -180,17 +181,17 @@ const CreateUploadModalBody = ({ ); if ( - options.maxExpirationInHours != 0 && + options.maxExpiration.value != 0 && (form.values.never_expires || expirationDate.isAfter( - moment().add(options.maxExpirationInHours, "hours"), + moment().add(options.maxExpiration.value, options.maxExpiration.unit), )) ) { form.setFieldError( "expiration_num", t("upload.modal.expires.error.too-long", { max: moment - .duration(options.maxExpirationInHours, "hours") + .duration(options.maxExpiration.value, options.maxExpiration.unit) .humanize(), }), ); @@ -327,7 +328,7 @@ const CreateUploadModalBody = ({ /> - {options.maxExpirationInHours == 0 && ( + {options.maxExpiration.value == 0 && ( { diff --git a/frontend/src/i18n/translations/de-DE.ts b/frontend/src/i18n/translations/de-DE.ts index db0e62a..8607a83 100644 --- a/frontend/src/i18n/translations/de-DE.ts +++ b/frontend/src/i18n/translations/de-DE.ts @@ -344,7 +344,7 @@ export default { "admin.config.share.allow-unauthenticated-shares": "Nicht authentifizierte Freigaben erlauben", "admin.config.share.allow-unauthenticated-shares.description": "Gibt an, ob nicht authentifizierte Benutzer Freigaben erstellen können", "admin.config.share.max-expiration": "Max. Ablaufdatum", - "admin.config.share.max-expiration.description": "Maximale Ablaufzeit in Stunden. Auf 0 setzen, um kein Ablaufdatum zu definieren.", + "admin.config.share.max-expiration.description": "Maximale Ablaufzeit. Auf 0 setzen, um kein Ablaufdatum zu definieren.", "admin.config.share.share-id-length": "Default share ID length", "admin.config.share.share-id-length.description": "Default length for the generated ID of a share. This value is also used to generate links for reverse shares. A value below 8 is not considered secure.", "admin.config.share.max-size": "Maximale Größe", diff --git a/frontend/src/i18n/translations/en-US.ts b/frontend/src/i18n/translations/en-US.ts index 991441f..1b3a968 100644 --- a/frontend/src/i18n/translations/en-US.ts +++ b/frontend/src/i18n/translations/en-US.ts @@ -478,7 +478,7 @@ export default { "Whether unauthenticated users can create shares", "admin.config.share.max-expiration": "Max expiration", "admin.config.share.max-expiration.description": - "Maximum share expiration in hours. Set to 0 to allow unlimited expiration.", + "Maximum share expiration. Set to 0 to allow unlimited expiration.", "admin.config.share.share-id-length": "Default share ID length", "admin.config.share.share-id-length.description": "Default length for the generated ID of a share. This value is also used to generate links for reverse shares. A value below 8 is not considered secure.", diff --git a/frontend/src/pages/upload/index.tsx b/frontend/src/pages/upload/index.tsx index 5c094c5..e0626f2 100644 --- a/frontend/src/pages/upload/index.tsx +++ b/frontend/src/pages/upload/index.tsx @@ -139,7 +139,7 @@ const Upload = ({ "share.allowUnauthenticatedShares", ), enableEmailRecepients: config.get("email.enableShareEmailRecipients"), - maxExpirationInHours: config.get("share.maxExpiration"), + maxExpiration: config.get("share.maxExpiration"), shareIdLength: config.get("share.shareIdLength"), simplified, }, diff --git a/frontend/src/services/config.service.ts b/frontend/src/services/config.service.ts index a518604..dcbe776 100644 --- a/frontend/src/services/config.service.ts +++ b/frontend/src/services/config.service.ts @@ -1,6 +1,7 @@ import axios from "axios"; import Config, { AdminConfig, UpdateConfig } from "../types/config.type"; import api from "./api.service"; +import { stringToTimespan } from "../utils/date.util"; const list = async (): Promise => { return (await api.get("/configs")).data; @@ -30,6 +31,8 @@ const get = (key: string, configVariables: Config[]): any => { if (configVariable.type == "boolean") return value == "true"; if (configVariable.type == "string" || configVariable.type == "text") return value; + if (configVariable.type == "timespan") + return stringToTimespan(value); }; const finishSetup = async (): Promise => { diff --git a/frontend/src/types/timespan.type.ts b/frontend/src/types/timespan.type.ts new file mode 100644 index 0000000..21c60d5 --- /dev/null +++ b/frontend/src/types/timespan.type.ts @@ -0,0 +1,2 @@ +export type TimeUnit = "minutes" | "hours" | "days" | "weeks" | "months" | "years"; +export type Timespan = { value: number; unit: TimeUnit }; diff --git a/frontend/src/utils/date.util.ts b/frontend/src/utils/date.util.ts index 166d1a9..6409e77 100644 --- a/frontend/src/utils/date.util.ts +++ b/frontend/src/utils/date.util.ts @@ -1,4 +1,5 @@ import moment from "moment"; +import { Timespan } from "../types/timespan.type"; export const getExpirationPreview = ( messages: { @@ -30,3 +31,14 @@ export const getExpirationPreview = ( moment(expirationDate).format("LLL"), ); }; + +export const timespanToString = (timespan: Timespan) => { + return `${timespan.value} ${timespan.unit}`; +} + +export const stringToTimespan = (value: string): Timespan => { + return { + value: parseInt(value.split(" ")[0]), + unit: value.split(" ")[1], + } as Timespan; +};