Compare commits

...

2 Commits

Author SHA1 Message Date
577f6f3ddf
materia-web-client: repository view 2024-07-10 01:21:21 +05:00
b89e8f3393
materia-server: fix directory no parent 2024-07-10 01:20:51 +05:00
18 changed files with 587 additions and 290 deletions

449
flake.nix
View File

@ -1,227 +1,258 @@
{ {
description = "Materia"; description = "Materia";
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
dream2nix = { dream2nix = {
url = "github:nix-community/dream2nix"; url = "github:nix-community/dream2nix";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
};
bonfire.url = "github:L-Nafaryus/bonfire";
}; };
bonfire.url = "github:L-Nafaryus/bonfire";
};
outputs = {
self,
nixpkgs,
dream2nix,
bonfire,
...
}: let
system = "x86_64-linux";
pkgs = import nixpkgs {inherit system;};
bonpkgs = bonfire.packages.${system};
bonlib = bonfire.lib;
outputs = { self, nixpkgs, dream2nix, bonfire, ... }: dreamBuildPackage = {
let module,
system = "x86_64-linux"; meta ? {},
pkgs = import nixpkgs { inherit system; }; extraModules ? [],
bonpkgs = bonfire.packages.${system}; extraArgs ? {},
bonlib = bonfire.lib; }:
(
dreamBuildPackage = { module, meta ? {}, extraModules ? [], extraArgs ? {} }: ( nixpkgs.lib.evalModules {
nixpkgs.lib.evalModules { modules = [module] ++ extraModules;
modules = [ module ] ++ extraModules; specialArgs =
specialArgs = { {
inherit dream2nix; inherit dream2nix;
packageSets.nixpkgs = pkgs; packageSets.nixpkgs = pkgs;
} // extraArgs;
} }
).config.public // { inherit meta; }; // extraArgs;
}
)
.config
.public
// {inherit meta;};
in {
packages.x86_64-linux = {
materia-frontend = dreamBuildPackage {
module = {
lib,
config,
dream2nix,
...
}: {
name = "materia-frontend";
version = "0.0.1";
in imports = [
{ dream2nix.modules.dream2nix.WIP-nodejs-builder-v3
packages.x86_64-linux = { ];
materia-frontend = dreamBuildPackage {
module = { lib, config, dream2nix, ... }: {
name = "materia-frontend";
version = "0.0.1";
imports = [ mkDerivation = {
dream2nix.modules.dream2nix.WIP-nodejs-builder-v3 src = ./materia-web-client/src/materia-frontend;
]; };
mkDerivation = { deps = {nixpkgs, ...}: {
src = ./materia-web-client/src/materia-frontend; inherit
}; (nixpkgs)
fetchFromGitHub
stdenv
;
};
deps = {nixpkgs, ...}: { WIP-nodejs-builder-v3 = {
inherit packageLockFile = "${config.mkDerivation.src}/package-lock.json";
(nixpkgs) };
fetchFromGitHub };
stdenv meta = with nixpkgs.lib; {
; description = "Materia frontend";
}; license = licenses.mit;
maintainers = with bonlib.maintainers; [L-Nafaryus];
broken = false;
};
};
WIP-nodejs-builder-v3 = { materia-web-client = dreamBuildPackage {
packageLockFile = "${config.mkDerivation.src}/package-lock.json"; extraArgs = {
}; inherit (self.packages.x86_64-linux) materia-frontend;
}; };
meta = with nixpkgs.lib; { module = {
description = "Materia frontend"; config,
license = licenses.mit; lib,
maintainers = with bonlib.maintainers; [ L-Nafaryus ]; dream2nix,
broken = false; materia-frontend,
}; ...
}; }: {
imports = [dream2nix.modules.dream2nix.WIP-python-pdm];
materia-web-client = dreamBuildPackage {
extraArgs = { pdm.lockfile = ./materia-web-client/pdm.lock;
inherit (self.packages.x86_64-linux) materia-frontend; pdm.pyproject = ./materia-web-client/pyproject.toml;
};
module = {config, lib, dream2nix, materia-frontend, ...}: { deps = _: {
imports = [ dream2nix.modules.dream2nix.WIP-python-pdm ]; python = pkgs.python3;
};
pdm.lockfile = ./materia-web-client/pdm.lock;
pdm.pyproject = ./materia-web-client/pyproject.toml; mkDerivation = {
src = ./materia-web-client;
deps = _ : { buildInputs = [
python = pkgs.python3; pkgs.python3.pkgs.pdm-backend
}; ];
configurePhase = ''
mkDerivation = { cp -rv ${materia-frontend}/dist ./src/materia-frontend/
src = ./materia-web-client; '';
buildInputs = [ };
pkgs.python3.pkgs.pdm-backend };
]; meta = with nixpkgs.lib; {
configurePhase = '' description = "Materia web client";
cp -rv ${materia-frontend}/dist ./src/materia-frontend/ license = licenses.mit;
''; maintainers = with bonlib.maintainers; [L-Nafaryus];
}; broken = false;
}; };
meta = with nixpkgs.lib; { };
description = "Materia web client";
license = licenses.mit; materia-server = dreamBuildPackage {
maintainers = with bonlib.maintainers; [ L-Nafaryus ]; module = {
broken = false; config,
}; lib,
}; dream2nix,
materia-frontend,
materia-server = dreamBuildPackage { ...
module = {config, lib, dream2nix, materia-frontend, ...}: { }: {
imports = [ dream2nix.modules.dream2nix.WIP-python-pdm ]; imports = [dream2nix.modules.dream2nix.WIP-python-pdm];
pdm.lockfile = ./materia-server/pdm.lock; pdm.lockfile = ./materia-server/pdm.lock;
pdm.pyproject = ./materia-server/pyproject.toml; pdm.pyproject = ./materia-server/pyproject.toml;
deps = _ : { deps = _: {
python = pkgs.python3; python = pkgs.python3;
}; };
mkDerivation = { mkDerivation = {
src = ./materia-server; src = ./materia-server;
buildInputs = [ buildInputs = [
pkgs.python3.pkgs.pdm-backend pkgs.python3.pkgs.pdm-backend
]; ];
nativeBuildInputs = [ nativeBuildInputs = [
pkgs.python3.pkgs.wrapPython pkgs.python3.pkgs.wrapPython
]; ];
};
}; };
}; meta = with nixpkgs.lib; {
meta = with nixpkgs.lib; { description = "Materia";
description = "Materia"; license = licenses.mit;
license = licenses.mit; maintainers = with bonlib.maintainers; [L-Nafaryus];
maintainers = with bonlib.maintainers; [ L-Nafaryus ]; broken = false;
broken = false; mainProgram = "materia-server";
mainProgram = "materia-server"; };
}; };
};
postgresql = let
postgresql = let user = "postgres";
user = "postgres"; database = "postgres";
database = "postgres"; dataDir = "/var/lib/postgresql";
dataDir = "/var/lib/postgresql"; entryPoint = pkgs.writeTextDir "entrypoint.sh" ''
entryPoint = pkgs.writeTextDir "entrypoint.sh" '' initdb -U ${user}
initdb -U ${user} postgres -k ${dataDir}
postgres -k ${dataDir} '';
''; in
in pkgs.dockerTools.buildImage { pkgs.dockerTools.buildImage {
name = "postgresql"; name = "postgresql";
tag = "devel"; tag = "devel";
copyToRoot = pkgs.buildEnv { copyToRoot = pkgs.buildEnv {
name = "image-root"; name = "image-root";
pathsToLink = [ "/bin" "/etc" "/" ]; pathsToLink = ["/bin" "/etc" "/"];
paths = with pkgs; [ paths = with pkgs; [
bash bash
postgresql postgresql
entryPoint entryPoint
]; ];
}; };
runAsRoot = with pkgs; '' runAsRoot = with pkgs; ''
#!${runtimeShell} #!${runtimeShell}
${dockerTools.shadowSetup} ${dockerTools.shadowSetup}
groupadd -r ${user} groupadd -r ${user}
useradd -r -g ${user} --home-dir=${dataDir} ${user} useradd -r -g ${user} --home-dir=${dataDir} ${user}
mkdir -p ${dataDir} mkdir -p ${dataDir}
chown -R ${user}:${user} ${dataDir} chown -R ${user}:${user} ${dataDir}
''; '';
config = { config = {
Entrypoint = [ "bash" "/entrypoint.sh" ]; Entrypoint = ["bash" "/entrypoint.sh"];
StopSignal = "SIGINT"; StopSignal = "SIGINT";
User = "${user}:${user}"; User = "${user}:${user}";
Env = [ "PGDATA=${dataDir}" ]; Env = ["PGDATA=${dataDir}"];
WorkingDir = dataDir; WorkingDir = dataDir;
ExposedPorts = { ExposedPorts = {
"5432/tcp" = {}; "5432/tcp" = {};
};
};
};
redis = let
user = "redis";
dataDir = "/var/lib/redis";
entryPoint = pkgs.writeTextDir "entrypoint.sh" ''
redis-server \
--daemonize no \
--dir "${dataDir}"
'';
in pkgs.dockerTools.buildImage {
name = "redis";
tag = "devel";
copyToRoot = pkgs.buildEnv {
name = "image-root";
pathsToLink = [ "/bin" "/etc" "/" ];
paths = with pkgs; [
bash
redis
entryPoint
];
};
runAsRoot = with pkgs; ''
#!${runtimeShell}
${dockerTools.shadowSetup}
groupadd -r ${user}
useradd -r -g ${user} --home-dir=${dataDir} ${user}
mkdir -p ${dataDir}
chown -R ${user}:${user} ${dataDir}
'';
config = {
Entrypoint = [ "bash" "/entrypoint.sh" ];
StopSignal = "SIGINT";
User = "${user}:${user}";
WorkingDir = dataDir;
ExposedPorts = {
"6379/tcp" = {};
};
};
}; };
};
}; };
apps.x86_64-linux = { redis = let
materia-server = { user = "redis";
type = "app"; dataDir = "/var/lib/redis";
program = "${self.packages.x86_64-linux.materia-server}/bin/materia-server"; entryPoint = pkgs.writeTextDir "entrypoint.sh" ''
}; redis-server \
}; --daemonize no \
--dir "${dataDir}"
'';
in
pkgs.dockerTools.buildImage {
name = "redis";
tag = "devel";
devShells.x86_64-linux.default = pkgs.mkShell { copyToRoot = pkgs.buildEnv {
buildInputs = with pkgs; [ postgresql redis pdm nodejs ]; name = "image-root";
# greenlet requires libstdc++ pathsToLink = ["/bin" "/etc" "/"];
LD_LIBRARY_PATH = nixpkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ]; paths = with pkgs; [
bash
redis
entryPoint
];
};
runAsRoot = with pkgs; ''
#!${runtimeShell}
${dockerTools.shadowSetup}
groupadd -r ${user}
useradd -r -g ${user} --home-dir=${dataDir} ${user}
mkdir -p ${dataDir}
chown -R ${user}:${user} ${dataDir}
'';
config = {
Entrypoint = ["bash" "/entrypoint.sh"];
StopSignal = "SIGINT";
User = "${user}:${user}";
WorkingDir = dataDir;
ExposedPorts = {
"6379/tcp" = {};
};
};
}; };
}; };
apps.x86_64-linux = {
materia-server = {
type = "app";
program = "${self.packages.x86_64-linux.materia-server}/bin/materia-server";
};
};
devShells.x86_64-linux.default = pkgs.mkShell {
buildInputs = with pkgs; [postgresql redis pdm nodejs];
# greenlet requires libstdc++
LD_LIBRARY_PATH = nixpkgs.lib.makeLibraryPath [pkgs.stdenv.cc.cc];
};
};
} }

View File

@ -1,11 +1,26 @@
from materia_server.models.auth import (
LoginType,
LoginSource,
OAuth2Application,
OAuth2Grant,
OAuth2AuthorizationCode,
)
from materia_server.models.auth import LoginType, LoginSource, OAuth2Application, OAuth2Grant, OAuth2AuthorizationCode from materia_server.models.database import (
Database,
from materia_server.models.database import Database, DatabaseError, DatabaseMigrationError, Cache, CacheError DatabaseError,
DatabaseMigrationError,
Cache,
CacheError,
)
from materia_server.models.user import User, UserCredentials, UserInfo from materia_server.models.user import User, UserCredentials, UserInfo
from materia_server.models.repository import Repository, RepositoryInfo from materia_server.models.repository import (
Repository,
RepositoryInfo,
RepositoryContent,
)
from materia_server.models.directory import Directory, DirectoryLink, DirectoryInfo from materia_server.models.directory import Directory, DirectoryLink, DirectoryInfo

View File

@ -59,7 +59,7 @@ class Directory(Base):
async def remove(self, db: database.Database): async def remove(self, db: database.Database):
async with db.session() as session: async with db.session() as session:
await session.execute(sa.delete(Directory).where(Directory.id == self.id)) await session.delete(self)
await session.commit() await session.commit()

View File

@ -56,7 +56,7 @@ class File(Base):
async def remove(self, db: database.Database): async def remove(self, db: database.Database):
async with db.session() as session: async with db.session() as session:
await session.execute(sa.delete(File).where(File.id == self.id)) await session.delete(self)
await session.commit() await session.commit()

View File

@ -1,12 +1,12 @@
from time import time from time import time
from typing import List, Self from typing import List, Self, Optional
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import BigInteger, ForeignKey from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped, relationship from sqlalchemy.orm import mapped_column, Mapped, relationship
from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.attributes import InstrumentedAttribute
import sqlalchemy as sa import sqlalchemy as sa
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
from materia_server.models.base import Base from materia_server.models.base import Base
from materia_server.models import database from materia_server.models import database
@ -55,15 +55,24 @@ class Repository(Base):
async def remove(self, db: database.Database): async def remove(self, db: database.Database):
async with db.session() as session: async with db.session() as session:
await session.execute(sa.delete(Repository).where(Repository.id == self.id)) await session.delete(self)
await session.commit() await session.commit()
class RepositoryInfo(BaseModel): class RepositoryInfo(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
capacity: int capacity: int
used: int used: Optional[int] = None
class RepositoryContent(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
files: list["FileInfo"]
directories: list["DirectoryInfo"]
from materia_server.models.user import User from materia_server.models.user import User
from materia_server.models.directory import Directory from materia_server.models.directory import Directory, DirectoryInfo
from materia_server.models.file import File from materia_server.models.file import File, FileInfo

View File

@ -82,7 +82,7 @@ class User(Base):
async def remove(self, db: database.Database): async def remove(self, db: database.Database):
async with db.session() as session: async with db.session() as session:
await session.execute(sa.delete(User).where(User.id == self.id)) await session.delete(self)
await session.commit() await session.commit()

View File

@ -28,36 +28,39 @@ async def create(
session.add(user) session.add(user)
await session.refresh(user, attribute_names=["repository"]) await session.refresh(user, attribute_names=["repository"])
if not user.repository: if not user.repository:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found") raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found")
current_directory = None current_directory = None
current_path = Path() current_path = Path()
directory = None directory = None
for part in directory_path.parts: for part in directory_path.parts:
if not await Directory.by_path( if not await Directory.by_path(
user.repository.id, current_path, part, ctx.database user.repository.id, current_path, part, ctx.database
): ):
directory = Directory( directory = Directory(
repository_id=user.repository.id, repository_id=user.repository.id,
parent_id=current_directory.id if current_directory else None, parent_id=current_directory.id if current_directory else None,
name=part, name=part,
path=None if current_path == Path() else str(current_path), path=None if current_path == Path() else str(current_path),
)
session.add(directory)
current_directory = directory
current_path /= part
try:
(repository_path / directory_path).mkdir(parents=True, exist_ok=True)
except OSError:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to created a directory"
) )
await session.commit() try:
(repository_path / current_path / part).mkdir(exist_ok=True)
except OSError:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
f"Failed to create a directory {current_path / part}",
)
async with ctx.database.session() as session:
session.add(directory)
await session.commit()
await session.refresh(directory)
current_directory = directory
current_path /= part
@router.get("/directory") @router.get("/directory")

View File

@ -1,7 +1,15 @@
import shutil import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from materia_server.models import User, Repository, RepositoryInfo from materia_server.models import (
User,
Repository,
RepositoryInfo,
RepositoryContent,
FileInfo,
DirectoryInfo,
)
from materia_server.routers import middleware from materia_server.routers import middleware
from materia_server.config import Config from materia_server.config import Config
@ -32,35 +40,24 @@ async def create(
@router.get("/repository", response_model=RepositoryInfo) @router.get("/repository", response_model=RepositoryInfo)
async def info( async def info(
user: User = Depends(middleware.user), ctx: middleware.Context = Depends() repository=Depends(middleware.repository), ctx: middleware.Context = Depends()
): ):
async with ctx.database.session() as session:
session.add(user)
await session.refresh(user, attribute_names=["repository"])
if not (repository := user.repository):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found")
async with ctx.database.session() as session: async with ctx.database.session() as session:
session.add(repository) session.add(repository)
await session.refresh(repository, attribute_names=["files"]) await session.refresh(repository, attribute_names=["files"])
return RepositoryInfo( info = RepositoryInfo.model_validate(repository)
capacity=repository.capacity, info.used = sum([file.size for file in repository.files])
used=sum([file.size for file in repository.files]),
) return info
@router.delete("/repository") @router.delete("/repository")
async def remove( async def remove(
user: User = Depends(middleware.user), ctx: middleware.Context = Depends() repository=Depends(middleware.repository),
repository_path=Depends(middleware.repository_path),
ctx: middleware.Context = Depends(),
): ):
repository_path = Config.data_dir() / "repository" / user.lower_name
async with ctx.database.session() as session:
session.add(user)
await session.refresh(user, attribute_names=["repository"])
try: try:
if repository_path.exists(): if repository_path.exists():
shutil.rmtree(str(repository_path)) shutil.rmtree(str(repository_path))
@ -69,4 +66,33 @@ async def remove(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to remove repository" status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to remove repository"
) )
await user.repository.remove(ctx.database) await repository.remove(ctx.database)
@router.get("/repository/content", response_model=RepositoryContent)
async def content(
repository=Depends(middleware.repository), ctx: middleware.Context = Depends()
):
async with ctx.database.session() as session:
session.add(repository)
await session.refresh(repository, attribute_names=["directories"])
await session.refresh(repository, attribute_names=["files"])
content = RepositoryContent(
files=list(
map(
lambda file: FileInfo.model_validate(file),
filter(lambda file: file.path is None, repository.files),
)
),
directories=list(
map(
lambda directory: DirectoryInfo.model_validate(directory),
filter(
lambda directory: directory.path is None, repository.directories
),
)
),
)
return content

View File

@ -1,67 +1,81 @@
from typing import Optional, Sequence from typing import Optional, Sequence
import uuid import uuid
from datetime import datetime from datetime import datetime
from pathlib import Path
from fastapi import HTTPException, Request, Response, status, Depends, Cookie from fastapi import HTTPException, Request, Response, status, Depends, Cookie
from fastapi.security.base import SecurityBase from fastapi.security.base import SecurityBase
import jwt import jwt
from sqlalchemy import select from sqlalchemy import select
from pydantic import BaseModel from pydantic import BaseModel
from enum import StrEnum from enum import StrEnum
from http import HTTPMethod as HttpMethod from http import HTTPMethod as HttpMethod
from fastapi.security import HTTPBearer, OAuth2PasswordBearer, OAuth2PasswordRequestForm, APIKeyQuery, APIKeyCookie, APIKeyHeader from fastapi.security import (
HTTPBearer,
OAuth2PasswordBearer,
OAuth2PasswordRequestForm,
APIKeyQuery,
APIKeyCookie,
APIKeyHeader,
)
from materia_server import security from materia_server import security
from materia_server.models import User from materia_server.models import User, Repository
class Context: class Context:
def __init__(self, request: Request): def __init__(self, request: Request):
self.config = request.state.config self.config = request.state.config
self.database = request.state.database self.database = request.state.database
self.cache = request.state.cache self.cache = request.state.cache
self.logger = request.state.logger self.logger = request.state.logger
async def jwt_cookie(request: Request, response: Response, ctx: Context = Depends()): async def jwt_cookie(request: Request, response: Response, ctx: Context = Depends()):
if not (access_token := request.cookies.get(ctx.config.security.cookie_access_token_name)): if not (
access_token := request.cookies.get(
ctx.config.security.cookie_access_token_name
)
):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing token") raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing token")
refresh_token = request.cookies.get(ctx.config.security.cookie_refresh_token_name) refresh_token = request.cookies.get(ctx.config.security.cookie_refresh_token_name)
if ctx.config.oauth2.jwt_signing_algo in ["HS256", "HS384", "HS512"]: if ctx.config.oauth2.jwt_signing_algo in ["HS256", "HS384", "HS512"]:
secret = ctx.config.oauth2.jwt_secret secret = ctx.config.oauth2.jwt_secret
else: else:
secret = ctx.config.oauth2.jwt_signing_key secret = ctx.config.oauth2.jwt_signing_key
issuer = "{}://{}".format(ctx.config.server.scheme, ctx.config.server.domain) issuer = "{}://{}".format(ctx.config.server.scheme, ctx.config.server.domain)
try: try:
refresh_claims = security.validate_token(refresh_token, secret) if refresh_token else None refresh_claims = (
security.validate_token(refresh_token, secret) if refresh_token else None
)
if refresh_claims: if refresh_claims:
if refresh_claims.exp < datetime.now().timestamp(): if refresh_claims.exp < datetime.now().timestamp():
refresh_claims = None refresh_claims = None
except jwt.PyJWTError: except jwt.PyJWTError:
refresh_claims = None refresh_claims = None
try: try:
access_claims = security.validate_token(access_token, secret) access_claims = security.validate_token(access_token, secret)
if access_claims.exp < datetime.now().timestamp(): if access_claims.exp < datetime.now().timestamp():
if refresh_claims: if refresh_claims:
new_access_token = security.generate_token( new_access_token = security.generate_token(
access_claims.sub, access_claims.sub,
str(secret), str(secret),
ctx.config.oauth2.access_token_lifetime, ctx.config.oauth2.access_token_lifetime,
issuer issuer,
) )
access_claims = security.validate_token(new_access_token, secret) access_claims = security.validate_token(new_access_token, secret)
response.set_cookie( response.set_cookie(
ctx.config.security.cookie_access_token_name, ctx.config.security.cookie_access_token_name,
value = new_access_token, value=new_access_token,
max_age = ctx.config.oauth2.access_token_lifetime, max_age=ctx.config.oauth2.access_token_lifetime,
secure = True, secure=True,
httponly = ctx.config.security.cookie_http_only, httponly=ctx.config.security.cookie_http_only,
samesite = "lax" samesite="lax",
) )
else: else:
access_claims = None access_claims = None
@ -74,8 +88,23 @@ async def jwt_cookie(request: Request, response: Response, ctx: Context = Depend
return access_claims return access_claims
async def user(claims = Depends(jwt_cookie), ctx: Context = Depends()): async def user(claims=Depends(jwt_cookie), ctx: Context = Depends()) -> User:
if not (current_user := await User.by_id(uuid.UUID(claims.sub), ctx.database)): if not (current_user := await User.by_id(uuid.UUID(claims.sub), ctx.database)):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing user") raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing user")
return current_user return current_user
async def repository(user: User = Depends(user), ctx: Context = Depends()):
async with ctx.database.session() as session:
session.add(user)
await session.refresh(user, attribute_names=["repository"])
if not (repository := user.repository):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found")
return repository
async def repository_path(user: User = Depends(user), ctx: Context = Depends()) -> Path:
return ctx.config.data_dir() / "repository" / user.lower_name

View File

@ -0,0 +1,13 @@
import { api_client, type ResponseError, handle_error } from "@/client";
export interface DirectoryInfo {
id: number,
repository_id: number,
parent_id?: number,
created: number,
updated: number,
name: string,
path?: string,
is_public: boolean,
used?: number
}

View File

@ -0,0 +1,13 @@
import { api_client, type ResponseError, handle_error } from "@/client";
export interface FileInfo {
id: number,
repository_id: number,
parent_id?: number,
created: number,
updated: number,
name: string,
path?: string,
is_public: boolean,
size: number
}

View File

@ -1,3 +1,5 @@
export * as auth from "@/api/auth"; export * as auth from "@/api/auth";
export * as user from "@/api/user"; export * as user from "@/api/user";
export * as repository from "@/api/repository"; export * as repository from "@/api/repository";
export * as directory from "@/api/directory";
export * as file from "@/api/file";

View File

@ -1,4 +1,5 @@
import { api_client, type ResponseError, handle_error } from "@/client"; import { api_client, type ResponseError, handle_error } from "@/client";
import { file, directory } from "@/api"
export interface RepositoryInfo { export interface RepositoryInfo {
id: number, id: number,
@ -6,6 +7,11 @@ export interface RepositoryInfo {
used?: number used?: number
} }
export interface RepositoryContent {
files: file.FileInfo[],
directories: directory.DirectoryInfo[]
}
export async function info(): Promise<RepositoryInfo | ResponseError> { export async function info(): Promise<RepositoryInfo | ResponseError> {
return await api_client.get("/repository") return await api_client.get("/repository")
.then(async response => { return Promise.resolve<RepositoryInfo>(response.data); }) .then(async response => { return Promise.resolve<RepositoryInfo>(response.data); })
@ -21,3 +27,9 @@ export async function remove(): Promise<null | ResponseError> {
return await api_client.delete("/repository") return await api_client.delete("/repository")
.catch(handle_error); .catch(handle_error);
} }
export async function content(): Promise<RepositoryContent | ResponseError> {
return await api_client.get("/repository/content")
.then(async response => { return Promise.resolve<RepositoryContent>(response.data); })
.catch(handle_error);
}

View File

@ -0,0 +1,37 @@
<template>
<div class="fixed h-1/3 z-50 context-menu" :style="{ top: y + 'px', left: x + 'px' }">
<div v-for="action in actions" :key="action.action" @click="emitAction(action.action)">
{{ action.label }}
</div>
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits } from 'vue';
const { actions, x, y } = defineProps(['actions', 'x', 'y']);
const emit = defineEmits(['action-clicked']);
const emitAction = (action) => {
emit('action-clicked', action);
};
</script>
<style scoped>
.context-menu {
position: absolute;
background: white;
border: 1px solid #ccc;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
min-width: 150px;
}
.context-menu div {
padding: 10px;
cursor: pointer;
}
.context-menu div:hover {
background-color: #f0f0f0;
}
</style>

View File

@ -0,0 +1,9 @@
<script setup lang="ts">
</script>
<template>
<div class="fixed z-50 cursor-pointer" :style="{ top: pos_y + 'px', left: pos_x + 'px' }">
</div>
</template>

View File

@ -0,0 +1,9 @@
<template>
<svg aria-hidden="true" focusable="false" role="img" class="icon-directory" viewBox="0 0 16 16" width="16"
height="16" fill="currentColor"
style="display: inline-block; user-select: none; vertical-align: text-bottom; overflow: visible;">
<path
d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z">
</path>
</svg>
</template>

View File

@ -0,0 +1,9 @@
<template>
<svg aria-hidden="true" focusable="false" role="img" class="icon-directory" viewBox="0 0 16 16" width="16"
height="16" fill="currentColor"
style="display: inline-block; user-select: none; vertical-align: text-bottom; overflow: visible;">
<path
d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z">
</path>
</svg>
</template>

View File

@ -1,6 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import Base from "@/views/Base.vue"; import Base from "@/views/Base.vue";
import Error from "@/components/Error.vue"; import Error from "@/components/Error.vue";
import IconDirectory from "@/components/icons/IconDirectory.vue";
import IconFile from "@/components/icons/IconFile.vue";
import ContextMenu from "@/components/ContextMenu.vue";
import { ref, onMounted, watch } from "vue"; import { ref, onMounted, watch } from "vue";
import { onBeforeRouteUpdate, useRoute } from "vue-router" import { onBeforeRouteUpdate, useRoute } from "vue-router"
@ -15,6 +18,7 @@ const error = ref<string>(null);
const repository_info = ref(null); const repository_info = ref(null);
const is_created = ref(null); const is_created = ref(null);
const repository_content = ref(null);
onMounted(async () => { onMounted(async () => {
await repository.info() await repository.info()
@ -25,6 +29,16 @@ onMounted(async () => {
.catch(err => { .catch(err => {
is_created.value = false; is_created.value = false;
}); });
if (is_created.value) {
await repository.content()
.then(async _repository_content => {
repository_content.value = _repository_content;
})
.catch(err => {
error.value = err;
})
}
}); });
async function create_repository() { async function create_repository() {
@ -57,6 +71,42 @@ function size_procent() {
return Math.round(repository_info.value.used / repository_info.value.capacity) * 100; return Math.round(repository_info.value.used / repository_info.value.capacity) * 100;
} }
function format_creation_time(timestamp: number): string {
const date = new Date(timestamp * 1000);
return `${date.getDate()}-${date.getMonth()}-${date.getFullYear()} ${date.getHours()}:${date.getMinutes()}`;
}
const showMenu = ref(false);
const ctxMenuPosX = ref(0);
const ctxMenuPosY = ref(0);
const targetRow = ref({});
const contextMenuActions = ref([
{ label: 'Edit', action: 'edit' },
{ label: 'Delete', action: 'delete' },
]);
const showContextMenu = (event, user) => {
event.preventDefault();
showMenu.value = true;
targetRow.value = user;
ctxMenuPosX.value = event.clientX;
ctxMenuPosY.value = event.clientY;
};
const closeContextMenu = () => {
showMenu.value = false;
};
function handleActionClick(action) {
console.log(action);
console.log(targetRow.value);
}
function close_menu() {
showMenu.value = false;
}
</script> </script>
<template> <template>
@ -67,9 +117,39 @@ function size_procent() {
<div class="w-full rounded-full h-2.5 bg-ctp-surface0"> <div class="w-full rounded-full h-2.5 bg-ctp-surface0">
<div class="bg-ctp-lavender h-2.5 rounded-full" :style="{ width: size_procent() + '%' }"></div> <div class="bg-ctp-lavender h-2.5 rounded-full" :style="{ width: size_procent() + '%' }"></div>
</div> </div>
<span class="min-w-32 text-center">{{ round_size(repository_info.used, "GB") }} / {{ <span class="min-w-48 text-center">{{ round_size(repository_info.used, "MB").toFixed(2) }} MB / {{
round_size(repository_info.capacity, "GB") }} GB</span> round_size(repository_info.capacity, "GB") }} GB</span>
</div> </div>
<table v-if="repository_content" class="table-auto w-full mt-8 mb-8 pl-8 pr-8 text-ctp-text">
<tbody>
<tr class="hover:bg-ctp-surface0" v-for="directory in repository_content.directories"
@contextmenu.prevent="showContextMenu($event, directory)">
<td>
<IconDirectory />
<div class="inline ml-4">{{ directory.name }}</div>
</td>
<td class="text-right">-</td>
<td class="text-right w-48">
{{ format_creation_time(directory.created) }}
</td>
</tr>
<tr class="hover:bg-ctp-surface0" v-for="file in repository_content.files">
<td>
<IconFile />
<div class="inline ml-4">{{ file.name }}</div>
</td>
<td class="text-right">{{ round_size(file.size, "MB").toFixed(2) }} MB</td>
<td class="text-right w-48">
{{ format_creation_time(file.created) }}
</td>
</tr>
</tbody>
</table>
<ContextMenu v-if="showMenu" :actions="contextMenuActions" @action-clicked="handleActionClick" :x="ctxMenuPosX"
:y="ctxMenuPosY" v-click-outside="close_menu" />
</section> </section>
<section v-else> <section v-else>
<p>It looks like you don't have a repository yet...</p> <p>It looks like you don't have a repository yet...</p>