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

View File

@ -10,29 +10,47 @@
bonfire.url = "github:L-Nafaryus/bonfire";
};
outputs = { self, nixpkgs, dream2nix, bonfire, ... }:
let
outputs = {
self,
nixpkgs,
dream2nix,
bonfire,
...
}: let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
pkgs = import nixpkgs {inherit system;};
bonpkgs = bonfire.packages.${system};
bonlib = bonfire.lib;
dreamBuildPackage = { module, meta ? {}, extraModules ? [], extraArgs ? {} }: (
dreamBuildPackage = {
module,
meta ? {},
extraModules ? [],
extraArgs ? {},
}:
(
nixpkgs.lib.evalModules {
modules = [ module ] ++ extraModules;
specialArgs = {
modules = [module] ++ extraModules;
specialArgs =
{
inherit dream2nix;
packageSets.nixpkgs = pkgs;
} // extraArgs;
}
).config.public // { inherit meta; };
in
{
// extraArgs;
}
)
.config
.public
// {inherit meta;};
in {
packages.x86_64-linux = {
materia-frontend = dreamBuildPackage {
module = { lib, config, dream2nix, ... }: {
module = {
lib,
config,
dream2nix,
...
}: {
name = "materia-frontend";
version = "0.0.1";
@ -59,7 +77,7 @@
meta = with nixpkgs.lib; {
description = "Materia frontend";
license = licenses.mit;
maintainers = with bonlib.maintainers; [ L-Nafaryus ];
maintainers = with bonlib.maintainers; [L-Nafaryus];
broken = false;
};
};
@ -68,13 +86,19 @@
extraArgs = {
inherit (self.packages.x86_64-linux) materia-frontend;
};
module = {config, lib, dream2nix, materia-frontend, ...}: {
imports = [ dream2nix.modules.dream2nix.WIP-python-pdm ];
module = {
config,
lib,
dream2nix,
materia-frontend,
...
}: {
imports = [dream2nix.modules.dream2nix.WIP-python-pdm];
pdm.lockfile = ./materia-web-client/pdm.lock;
pdm.pyproject = ./materia-web-client/pyproject.toml;
deps = _ : {
deps = _: {
python = pkgs.python3;
};
@ -91,19 +115,25 @@
meta = with nixpkgs.lib; {
description = "Materia web client";
license = licenses.mit;
maintainers = with bonlib.maintainers; [ L-Nafaryus ];
maintainers = with bonlib.maintainers; [L-Nafaryus];
broken = false;
};
};
materia-server = dreamBuildPackage {
module = {config, lib, dream2nix, materia-frontend, ...}: {
imports = [ dream2nix.modules.dream2nix.WIP-python-pdm ];
module = {
config,
lib,
dream2nix,
materia-frontend,
...
}: {
imports = [dream2nix.modules.dream2nix.WIP-python-pdm];
pdm.lockfile = ./materia-server/pdm.lock;
pdm.pyproject = ./materia-server/pyproject.toml;
deps = _ : {
deps = _: {
python = pkgs.python3;
};
@ -115,13 +145,12 @@
nativeBuildInputs = [
pkgs.python3.pkgs.wrapPython
];
};
};
meta = with nixpkgs.lib; {
description = "Materia";
license = licenses.mit;
maintainers = with bonlib.maintainers; [ L-Nafaryus ];
maintainers = with bonlib.maintainers; [L-Nafaryus];
broken = false;
mainProgram = "materia-server";
};
@ -135,13 +164,14 @@
initdb -U ${user}
postgres -k ${dataDir}
'';
in pkgs.dockerTools.buildImage {
in
pkgs.dockerTools.buildImage {
name = "postgresql";
tag = "devel";
copyToRoot = pkgs.buildEnv {
name = "image-root";
pathsToLink = [ "/bin" "/etc" "/" ];
pathsToLink = ["/bin" "/etc" "/"];
paths = with pkgs; [
bash
postgresql
@ -158,10 +188,10 @@
'';
config = {
Entrypoint = [ "bash" "/entrypoint.sh" ];
Entrypoint = ["bash" "/entrypoint.sh"];
StopSignal = "SIGINT";
User = "${user}:${user}";
Env = [ "PGDATA=${dataDir}" ];
Env = ["PGDATA=${dataDir}"];
WorkingDir = dataDir;
ExposedPorts = {
"5432/tcp" = {};
@ -177,13 +207,14 @@
--daemonize no \
--dir "${dataDir}"
'';
in pkgs.dockerTools.buildImage {
in
pkgs.dockerTools.buildImage {
name = "redis";
tag = "devel";
copyToRoot = pkgs.buildEnv {
name = "image-root";
pathsToLink = [ "/bin" "/etc" "/" ];
pathsToLink = ["/bin" "/etc" "/"];
paths = with pkgs; [
bash
redis
@ -200,7 +231,7 @@
'';
config = {
Entrypoint = [ "bash" "/entrypoint.sh" ];
Entrypoint = ["bash" "/entrypoint.sh"];
StopSignal = "SIGINT";
User = "${user}:${user}";
WorkingDir = dataDir;
@ -219,9 +250,9 @@
};
devShells.x86_64-linux.default = pkgs.mkShell {
buildInputs = with pkgs; [ postgresql redis pdm nodejs ];
buildInputs = with pkgs; [postgresql redis pdm nodejs];
# greenlet requires libstdc++
LD_LIBRARY_PATH = nixpkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ];
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, DatabaseError, DatabaseMigrationError, Cache, CacheError
from materia_server.models.database import (
Database,
DatabaseError,
DatabaseMigrationError,
Cache,
CacheError,
)
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

View File

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

View File

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

View File

@ -1,12 +1,12 @@
from time import time
from typing import List, Self
from typing import List, Self, Optional
from uuid import UUID, uuid4
from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import mapped_column, Mapped, relationship
from sqlalchemy.orm.attributes import InstrumentedAttribute
import sqlalchemy as sa
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from materia_server.models.base import Base
from materia_server.models import database
@ -55,15 +55,24 @@ class Repository(Base):
async def remove(self, db: database.Database):
async with db.session() as session:
await session.execute(sa.delete(Repository).where(Repository.id == self.id))
await session.delete(self)
await session.commit()
class RepositoryInfo(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: 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.directory import Directory
from materia_server.models.file import File
from materia_server.models.directory import Directory, DirectoryInfo
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 with db.session() as session:
await session.execute(sa.delete(User).where(User.id == self.id))
await session.delete(self)
await session.commit()

View File

@ -45,20 +45,23 @@ async def create(
name=part,
path=None if current_path == Path() else str(current_path),
)
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
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()
@router.get("/directory")
async def info(

View File

@ -1,7 +1,15 @@
import shutil
from pathlib import Path
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.config import Config
@ -32,35 +40,24 @@ async def create(
@router.get("/repository", response_model=RepositoryInfo)
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:
session.add(repository)
await session.refresh(repository, attribute_names=["files"])
return RepositoryInfo(
capacity=repository.capacity,
used=sum([file.size for file in repository.files]),
)
info = RepositoryInfo.model_validate(repository)
info.used = sum([file.size for file in repository.files])
return info
@router.delete("/repository")
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:
if repository_path.exists():
shutil.rmtree(str(repository_path))
@ -69,4 +66,33 @@ async def remove(
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,6 +1,7 @@
from typing import Optional, Sequence
import uuid
from datetime import datetime
from pathlib import Path
from fastapi import HTTPException, Request, Response, status, Depends, Cookie
from fastapi.security.base import SecurityBase
import jwt
@ -8,10 +9,17 @@ from sqlalchemy import select
from pydantic import BaseModel
from enum import StrEnum
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.models import User
from materia_server.models import User, Repository
class Context:
@ -23,7 +31,11 @@ class Context:
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")
refresh_token = request.cookies.get(ctx.config.security.cookie_refresh_token_name)
@ -35,7 +47,9 @@ async def jwt_cookie(request: Request, response: Response, ctx: Context = Depend
issuer = "{}://{}".format(ctx.config.server.scheme, ctx.config.server.domain)
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.exp < datetime.now().timestamp():
@ -52,16 +66,16 @@ async def jwt_cookie(request: Request, response: Response, ctx: Context = Depend
access_claims.sub,
str(secret),
ctx.config.oauth2.access_token_lifetime,
issuer
issuer,
)
access_claims = security.validate_token(new_access_token, secret)
response.set_cookie(
ctx.config.security.cookie_access_token_name,
value = new_access_token,
max_age = ctx.config.oauth2.access_token_lifetime,
secure = True,
httponly = ctx.config.security.cookie_http_only,
samesite = "lax"
value=new_access_token,
max_age=ctx.config.oauth2.access_token_lifetime,
secure=True,
httponly=ctx.config.security.cookie_http_only,
samesite="lax",
)
else:
access_claims = None
@ -74,8 +88,23 @@ async def jwt_cookie(request: Request, response: Response, ctx: Context = Depend
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)):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing 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 user from "@/api/user";
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 { file, directory } from "@/api"
export interface RepositoryInfo {
id: number,
@ -6,6 +7,11 @@ export interface RepositoryInfo {
used?: number
}
export interface RepositoryContent {
files: file.FileInfo[],
directories: directory.DirectoryInfo[]
}
export async function info(): Promise<RepositoryInfo | ResponseError> {
return await api_client.get("/repository")
.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")
.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">
import Base from "@/views/Base.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 { onBeforeRouteUpdate, useRoute } from "vue-router"
@ -15,6 +18,7 @@ const error = ref<string>(null);
const repository_info = ref(null);
const is_created = ref(null);
const repository_content = ref(null);
onMounted(async () => {
await repository.info()
@ -25,6 +29,16 @@ onMounted(async () => {
.catch(err => {
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() {
@ -57,6 +71,42 @@ function size_procent() {
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>
<template>
@ -67,9 +117,39 @@ function size_procent() {
<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>
<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>
</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 v-else>
<p>It looks like you don't have a repository yet...</p>