1
0

Merge paper and source code repo

This commit is contained in:
2025-04-27 12:14:28 +03:00
75 changed files with 9557 additions and 1 deletions

View File

@ -1,4 +1,4 @@
[Coursework](https://gitea.linerds.us/0x1D8/repo) paper, declared in [typst](https://typst.app/) v0.13, using [this template](https://gitea.linerds.us/pencelheimer/typst_nure_template).
> [!CAUTION]
> The [template.typ](template.typ) file was included for reproducibility, it has it's own [license terms](https://gitea.linerds.us/pencelheimer/typst_nure_template/src/branch/main/LICENSE).
> The [template.typ](template.typ) file was included for reproducibility, it has it's own [license terms](https://gitea.linerds.us/pencelheimer/typst_nure_template/src/branch/main/LICENSE) (GPL-3.0 at the time of writing).

2
src/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target/
/**/scrapyard/

5381
src/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
src/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "repo"
version = "0.1.0"
edition = "2024"
[dependencies]
data = { path = "data" }
service = { path = "service" }
iced = { version = "0.13.1", features = ["tokio", "lazy"] }
strum = { version = "0.27.0", features = ["derive"] }
open = "5.3.2"
[workspace]
resolver = "2"
members = ["data", "service"]

20
src/README.md Normal file
View File

@ -0,0 +1,20 @@
# Stuff that helped
* Architecture:
- [How to apply hexagonal architecture to Rust](https://www.barrage.net/blog/technology/how-to-apply-hexagonal-architecture-to-rust)
- [Implementing onion architecture using Rust](https://mathias-vandaele.dev/implementing-onion-architecture-using-rust)
* Design:
- [Rust Data Modelling Without Classes](https://www.youtube.com/watch?v=z-0-bbc80JM)
- ["Making Impossible States Impossible" by Richard Feldman](https://www.youtube.com/watch?v=IcgmSRJHu_8)
- [Pretty State Machine Patterns in Rust](https://hoverbear.org/blog/rust-state-machine-pattern/)
* How to Iced:
- [Building a simple text editor with iced, a cross-platform GUI library for Rust](https://www.youtube.com/watch?v=gcBJ7cPSALo)
- [Unofficial Iced Guide](https://jl710.github.io/iced-guide/)
- [icebreaker](https://github.com/hecrj/icebreaker)
- [Halloy](https://github.com/squidowl/halloy)
---
> _The purpose of abstraction is not to be vague, but to create a new semantic level in which one can be absolutely precise._
_Edsger W. Dijkstra_

11
src/assets/compose.yaml Normal file
View File

@ -0,0 +1,11 @@
services:
database:
image: mysql:9.0
restart: unless-stopped
ports:
- "127.0.0.1:3306:3306"
volumes:
- ../../repo-database:/var/lib/mysql
- ./init:/docker-entrypoint-initdb.d/:ro
environment:
MYSQL_ROOT_PASSWORD: password # yes, I know

191
src/assets/init/0-init.sql Normal file
View File

@ -0,0 +1,191 @@
-- DROP DATABASE IF EXISTS repository;
CREATE DATABASE repository;
USE repository;
-- Required info for an account
CREATE TABLE Users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(31) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
last_used TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
-- Enables multiple packages to have the same base yet different components
CREATE TABLE PackageBases (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(127) UNIQUE NOT NULL,
description VARCHAR(510) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- User roles for working on packages: flagger, packager, submitter, maintainer, etc.
CREATE TABLE PackageBaseRoles (
id TINYINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(31) UNIQUE NOT NULL,
description VARCHAR(255) NULL
);
INSERT INTO PackageBaseRoles (id, name) VALUES
(1, 'submitter'),
(2, 'packager'),
(3, 'maintainer'),
(4, 'flagger');
-- Roles that a user has for a package
CREATE TABLE PackageBaseUserRoles (
base INT UNSIGNED,
user INT UNSIGNED,
role TINYINT UNSIGNED,
comment VARCHAR(255) NULL,
PRIMARY KEY (base, user, role), -- composite key
FOREIGN KEY (base) REFERENCES PackageBases(id) ON DELETE CASCADE,
FOREIGN KEY (user) REFERENCES Users(id) ON DELETE CASCADE,
FOREIGN KEY (role) REFERENCES PackageBaseRoles(id) ON DELETE CASCADE
);
-- Information about the actual packages
CREATE TABLE Packages (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
base INT UNSIGNED NOT NULL,
name VARCHAR(127) UNIQUE NOT NULL,
version VARCHAR(127) NOT NULL,
description VARCHAR(255) NULL,
url VARCHAR(510) NULL,
flagged_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (base) REFERENCES PackageBases (id) ON DELETE CASCADE
);
-- depends, makedepends, optdepends, etc.
CREATE TABLE DependencyTypes (
id TINYINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(31) UNIQUE NOT NULL
);
INSERT INTO DependencyTypes (id, name) VALUES
(1, 'depends'),
(2, 'makedepends'),
(3, 'checkdepends'),
(4, 'optdepends');
-- Track which dependencies a package has
CREATE TABLE PackageDependencies (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
arch VARCHAR(63) NULL,
requirement VARCHAR(255) NULL,
description VARCHAR(127) NULL,
package INT UNSIGNED NOT NULL,
dependency_type TINYINT UNSIGNED NOT NULL,
dependency_package_name VARCHAR(127) NOT NULL, -- Not an actual package, but an an alias. Allows for package substitution.
FOREIGN KEY (package) REFERENCES Packages (id) ON DELETE CASCADE,
FOREIGN KEY (dependency_type) REFERENCES DependencyTypes (id)
);
-- conflicts, provides, replaces, etc.
CREATE TABLE RelationTypes (
id TINYINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(31) UNIQUE NOT NULL
);
INSERT INTO RelationTypes (id, name) VALUES
(1, 'conflicts'),
(2, 'provides'),
(3, 'replaces');
-- Track which conflicts, provides and replaces a package has
CREATE TABLE PackageRelations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
arch VARCHAR(63) NULL,
requirement VARCHAR(255) NULL,
package INT UNSIGNED NOT NULL,
relation_type TINYINT UNSIGNED NOT NULL,
relation_package_name VARCHAR(127) NOT NULL,
FOREIGN KEY (package) REFERENCES Packages (id) ON DELETE CASCADE,
FOREIGN KEY (relation_type) REFERENCES RelationTypes (id)
);
-- Public user profile
/* CREATE TABLE UserProfiles ( user_id INT UNSIGNED PRIMARY KEY,
real_name VARCHAR(63) NULL,
homepage TEXT NULL, -- bio / description / whatever
irc_nick VARCHAR(31) NULL,
pgp_key CHAR(40) NULL,
language VARCHAR(31) NULL, -- only for display
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE
); */
-- Settings for the User
/* CREATE TABLE UserPreferences ( user_id INT UNSIGNED PRIMARY KEY,
inactive BOOLEAN DEFAULT 0 NOT NULL, -- user is no longer active
show_email BOOLEAN DEFAULT 0 NOT NULL, -- on public profile page
utc_timezone TINYINT DEFAULT 0 NOT NULL, -- adjust timestamps shown
backup_email VARCHAR(127) NULL, -- to restore the account
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE
); */
-- Levels of access to the repository
/* CREATE TABLE AccessRoles ( id TINYINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(31) UNIQUE NOT NULL,
description VARCHAR(255) NULL
); */
-- Roles that a user has
/* CREATE TABLE UserAccessRoles (
user_id INT UNSIGNED,
role_id TINYINT UNSIGNED,
PRIMARY KEY (user_id, role_id), -- composite key
FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES AccessRoles(id) ON DELETE CASCADE
); */
-- Votes
/* CREATE TABLE PackageBaseUserVotes (
package_base INT UNSIGNED,
user INT UNSIGNED,
score TINYINT UNSIGNED DEFAULT 0 NOT NULL CHECK (score <= 10),
comment VARCHAR(255) NULL,
log TEXT NULL, -- error logs, etc.
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (package_base, user), -- composite key
FOREIGN KEY (package_base) REFERENCES PackageBases (id) ON DELETE CASCADE,
FOREIGN KEY (user) REFERENCES Users (id) ON DELETE CASCADE
); */
-- Information about licenses
/* CREATE TABLE Licenses ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(127) UNIQUE NOT NULL,
description TEXT NULL
); */
-- Information about licenses
/* CREATE TABLE PackageLicenses (
package INT UNSIGNED,
license INT UNSIGNED,
PRIMARY KEY (package, license), -- composite key
FOREIGN KEY (package) REFERENCES Packages (id) ON DELETE CASCADE,
FOREIGN KEY (license) REFERENCES Licenses (id) ON DELETE CASCADE
); */

103
src/assets/init/1-data.sql Normal file
View File

@ -0,0 +1,103 @@
-- Insert Users
INSERT INTO Users (name, email, password, last_used) VALUES
('alice', 'alice@example.com', 'password123', NOW()),
('bob', 'bob@example.com', 'securepass', NOW()),
('charlie', 'charlie@example.com', 'charliepwd', NOW()),
('dave', 'dave@example.com', 'davepass', NOW()),
('eve', 'eve@example.com', 'evepwd', NOW()),
('frank', 'frank@example.com', 'frankpass', NOW()),
('grace', 'grace@example.com', 'gracepwd', NOW()),
('heidi', 'heidi@example.com', 'heidipwd', NOW()),
('ivan', 'ivan@example.com', 'ivanpass', NOW()),
('judy', 'judy@example.com', 'judypass', NOW()),
('mallory', 'mallory@example.com', 'mallorypwd', NOW()),
('oscar', 'oscar@example.com', 'oscarpass', NOW()),
('peggy', 'peggy@example.com', 'peggypwd', NOW()),
('trent', 'trent@example.com', 'trentpass', NOW()),
('victor', 'victor@example.com', 'victorpwd', NOW());
-- Insert PackageBases
INSERT INTO PackageBases (name, description) VALUES
('libcore', 'Core system libraries'),
('webframework', 'A modern web framework'),
('dataproc', 'Data processing toolkit'),
('authmodule', 'Authentication and authorization module'),
('networkstack', 'Networking utilities and stack'),
('uikit', 'UI Kit for building interfaces'),
('cryptoengine', 'Cryptographic library'),
('dbconnector', 'Database connectivity drivers'),
('imageproc', 'Image processing library'),
('audiokit', 'Audio toolkit'),
('videokit', 'Video processing toolkit'),
('mlcore', 'Machine Learning core library'),
('analyticspro', 'Advanced analytics toolkit'),
('monitoragent', 'System monitoring agent'),
('filesystem', 'Filesystem utilities');
-- Assign Roles to Users for PackageBases
INSERT INTO PackageBaseUserRoles (base, user, role, comment) VALUES
(1, 1, 1, 'Original submitter'),
(1, 2, 2, 'Packager for latest release'),
(2, 3, 3, 'Maintains stability'),
(2, 4, 4, 'Flags issues'),
(3, 5, 1, 'Initial submission'),
(3, 6, 3, 'Lead maintainer'),
(4, 7, 2, 'Core packager'),
(5, 8, 1, 'Submitted new version'),
(6, 9, 4, 'Flagged for performance issues'),
(7, 10, 3, 'Maintainer for security fixes'),
(8, 11, 2, 'Driver package manager'),
(9, 12, 1, 'Original contributor'),
(10, 13, 3, 'Maintains core features'),
(11, 14, 4, 'Reported critical bug'),
(12, 15, 2, 'Optimized build process');
-- Insert Packages
INSERT INTO Packages (base, name, version, description, url) VALUES
(1, 'libcore-utils', '1.0.0', 'Utilities for libcore', 'http://example.com/libcore-utils'),
(1, 'libcore-extended', '1.1.0', 'Extended functionalities', 'http://example.com/libcore-extended'),
(2, 'webframework-api', '2.0.0', 'REST API module', 'http://example.com/webframework-api'),
(2, 'webframework-cli', '2.1.0', 'Command-line tools', 'http://example.com/webframework-cli'),
(3, 'dataproc-engine', '3.0.1', 'Data processing engine', 'http://example.com/dataproc-engine'),
(4, 'authmodule-oauth', '4.2.0', 'OAuth module', 'http://example.com/authmodule-oauth'),
(5, 'networkstack-core', '5.5.0', 'Core network stack', 'http://example.com/networkstack-core'),
(6, 'uikit-designer', '6.0.3', 'UI designer toolkit', 'http://example.com/uikit-designer'),
(7, 'cryptoengine-hash', '7.1.1', 'Hash algorithms', 'http://example.com/cryptoengine-hash'),
(8, 'dbconnector-mysql', '8.0.0', 'MySQL connector', 'http://example.com/dbconnector-mysql'),
(9, 'imageproc-filters', '9.3.0', 'Image filters library', 'http://example.com/imageproc-filters'),
(10, 'audiokit-mixer', '10.2.1', 'Audio mixing toolkit', 'http://example.com/audiokit-mixer'),
(11, 'videokit-stream', '11.4.0', 'Video streaming tools', 'http://example.com/videokit-stream'),
(12, 'mlcore-algo', '12.0.2', 'ML algorithms', 'http://example.com/mlcore-algo'),
(13, 'analyticspro-dashboard', '13.5.1', 'Analytics dashboard', 'http://example.com/analyticspro-dashboard');
-- Insert PackageDependencies
INSERT INTO PackageDependencies (arch, requirement, description, package, dependency_type, dependency_package_name) VALUES
('x86_64', '>=1.0.0', 'Core dependency', 3, 1, 'libcore-utils'),
('x86_64', '>=2.0.0', 'Required for API', 4, 2, 'webframework-api'),
('arm64', '>=3.0.1', 'Optional analytics', 5, 4, 'analyticspro-dashboard'),
('x86_64', '>=5.5.0', 'Network stack dependency', 6, 1, 'networkstack-core'),
('x86_64', '>=4.2.0', 'Authentication module', 7, 1, 'authmodule-oauth'),
('x86_64', NULL, 'Database driver', 8, 1, 'dbconnector-mysql'),
('arm64', NULL, 'Machine learning algorithms', 9, 3, 'mlcore-algo'),
('x86_64', '>=6.0.3', 'UI designer toolkit', 10, 1, 'uikit-designer'),
('x86_64', NULL, 'Audio toolkit dependency', 11, 2, 'audiokit-mixer'),
('x86_64', '>=7.1.1', 'Hash functions', 12, 1, 'cryptoengine-hash'),
('arm64', NULL, 'Video streaming tools', 13, 4, 'videokit-stream'),
('x86_64', '>=9.3.0', 'Image filters', 14, 1, 'imageproc-filters'),
('x86_64', NULL, 'System monitoring agent', 15, 2, 'monitoragent');
-- Insert PackageRelations
INSERT INTO PackageRelations (arch, requirement, package, relation_type, relation_package_name) VALUES
('x86_64', '>=1.0.0', 3, 1, 'legacy-web-api'), -- conflicts
('x86_64', NULL, 4, 2, 'web-cli-tools'), -- provides
('arm64', NULL, 5, 3, 'old-dataproc'), -- replaces
('x86_64', '>=5.0.0', 6, 1, 'net-tools-legacy'),
('x86_64', NULL, 7, 2, 'crypto-lib'),
('x86_64', '>=4.0.0', 8, 3, 'db-driver-old'),
('arm64', NULL, 9, 1, 'imgproc-v1'),
('x86_64', NULL, 10, 2, 'audio-tools'),
('x86_64', '>=7.0.0', 11, 3, 'video-kit-old'),
('x86_64', NULL, 12, 1, 'ml-core-legacy'),
('x86_64', '>=6.0.0', 13, 2, 'analytics-pro-tools'),
('x86_64', NULL, 14, 3, 'monitor-agent-v1'),
('x86_64', '>=9.0.0', 15, 1, 'filesystem-old');

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "INSERT INTO PackageBases (name, description, created_at, updated_at) VALUES (?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "014cf2ec55142a17047ad7c469685df75ae8e3c95a1a7c6c21be7b5624a82ae1"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE Packages SET created_at = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "063059de083c42956506d991bc04472929e6b2618ab13eb90e772ad9bd9c1984"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE PackageBases SET name = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "0af939868e37bad5eb9097badeaefca62c247c4b2265a9667c4b33885126c771"
}

View File

@ -0,0 +1,84 @@
{
"db_name": "MySQL",
"query": "SELECT * FROM Users WHERE email = ?",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"max_size": 10
}
},
{
"ordinal": 1,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | NO_DEFAULT_VALUE",
"max_size": 124
}
},
{
"ordinal": 2,
"name": "email",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | NO_DEFAULT_VALUE",
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "password",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | NO_DEFAULT_VALUE",
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "last_used",
"type_info": {
"type": "Timestamp",
"flags": "BINARY",
"max_size": 19
}
},
{
"ordinal": 5,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"max_size": 19
}
},
{
"ordinal": 6,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
false,
false
]
},
"hash": "0bb7353d64231dc12416f5504d94513493670e3f2ae017d87a2f0c3eca045f60"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE Packages SET version = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "346beb83d6351740a503b72133a190ac327ae79f6e555def8fec89fcc75fb015"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "DELETE FROM PackageBases WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "389e38e7e0a0b7d9ba667ac148a0a468da889a3455c47325938b819ab41ef4c8"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "INSERT INTO Packages (base, name, version, description, url, flagged_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "3ffe9168c1eb30bb54c65055314655c21f03d04973e4291e6d4b6c7847364659"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "DELETE FROM Users WHERE name = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "404747d44ee859e8c967695c29963594bb8273e66c053934ec20d5fc3db9d41e"
}

View File

@ -0,0 +1,84 @@
{
"db_name": "MySQL",
"query": "SELECT * FROM Users WHERE name = ?",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"max_size": 10
}
},
{
"ordinal": 1,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | NO_DEFAULT_VALUE",
"max_size": 124
}
},
{
"ordinal": 2,
"name": "email",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | NO_DEFAULT_VALUE",
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "password",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | NO_DEFAULT_VALUE",
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "last_used",
"type_info": {
"type": "Timestamp",
"flags": "BINARY",
"max_size": 19
}
},
{
"ordinal": 5,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"max_size": 19
}
},
{
"ordinal": 6,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
false,
false
]
},
"hash": "68ed36ae997fff190b4b15b80bf24b553d8ac922da251d9e8b8f4e897bab46b0"
}

View File

@ -0,0 +1,104 @@
{
"db_name": "MySQL",
"query": "SELECT * FROM Packages WHERE id = ?",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"max_size": 10
}
},
{
"ordinal": 1,
"name": "base",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | MULTIPLE_KEY | UNSIGNED | NO_DEFAULT_VALUE",
"max_size": 10
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | NO_DEFAULT_VALUE",
"max_size": 508
}
},
{
"ordinal": 3,
"name": "version",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | NO_DEFAULT_VALUE",
"max_size": 508
}
},
{
"ordinal": 4,
"name": "description",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 1020
}
},
{
"ordinal": 5,
"name": "url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2040
}
},
{
"ordinal": 6,
"name": "flagged_at",
"type_info": {
"type": "Timestamp",
"flags": "BINARY",
"max_size": 19
}
},
{
"ordinal": 7,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"max_size": 19
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
true,
true,
false,
false
]
},
"hash": "695f4b0a4286cf625dc60dc3dfc4a9cd92aaea3ea58ef8702903983cfc32ab47"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE Packages SET base = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "6b6f842d169e2a9628474edcd6dad10878bb9aaa612f62080d40ba24682b0c3b"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE Packages SET updated_at = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "7cc4cf73572c0830d1da7b8e621a79a09f3e3d8cfd42d3946bd1fac93838b913"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE PackageBases SET description = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "7f06016e9892486c938a5e94c9e5f70903a38ed314235712c28ac5e14d9ac20f"
}

View File

@ -0,0 +1,64 @@
{
"db_name": "MySQL",
"query": "SELECT * FROM PackageBases WHERE id = ?",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"max_size": 10
}
},
{
"ordinal": 1,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | NO_DEFAULT_VALUE",
"max_size": 508
}
},
{
"ordinal": 2,
"name": "description",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2040
}
},
{
"ordinal": 3,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"max_size": 19
}
},
{
"ordinal": 4,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
false
]
},
"hash": "839cea68f9de889f35a0d0ad0b48b4a0dc1af49f0f0e7bb12238d22a9c37fbbc"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE Users SET updated_at = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "8af7a0169e934cb82997a1cab04e921f719ed9466c13713fda8736c540d0fa78"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE Users SET password = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "8be76176b46f645095dce3bcbed11134ec0f43504d3a820698282848fd67dbad"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE Packages SET flagged_at = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "8bfaca937858ed1060da5a650f749849d29af3d6345d0e02474abf4d8c78b89d"
}

View File

@ -0,0 +1,84 @@
{
"db_name": "MySQL",
"query": "SELECT * FROM Users WHERE id = ?",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"max_size": 10
}
},
{
"ordinal": 1,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | NO_DEFAULT_VALUE",
"max_size": 124
}
},
{
"ordinal": 2,
"name": "email",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | NO_DEFAULT_VALUE",
"max_size": 1020
}
},
{
"ordinal": 3,
"name": "password",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | NO_DEFAULT_VALUE",
"max_size": 1020
}
},
{
"ordinal": 4,
"name": "last_used",
"type_info": {
"type": "Timestamp",
"flags": "BINARY",
"max_size": 19
}
},
{
"ordinal": 5,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"max_size": 19
}
},
{
"ordinal": 6,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
false,
false
]
},
"hash": "8e3ffe0d11d3eb38cd805771cd133588c0679404a68a8041f414553226abeeb2"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE PackageBases SET created_at = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "93ec7d124c9bfa7329478d975614db874788ed297fe2d95275592becb186f942"
}

View File

@ -0,0 +1,104 @@
{
"db_name": "MySQL",
"query": "SELECT * FROM Packages WHERE name = ?",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"max_size": 10
}
},
{
"ordinal": 1,
"name": "base",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | MULTIPLE_KEY | UNSIGNED | NO_DEFAULT_VALUE",
"max_size": 10
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | NO_DEFAULT_VALUE",
"max_size": 508
}
},
{
"ordinal": 3,
"name": "version",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | NO_DEFAULT_VALUE",
"max_size": 508
}
},
{
"ordinal": 4,
"name": "description",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 1020
}
},
{
"ordinal": 5,
"name": "url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2040
}
},
{
"ordinal": 6,
"name": "flagged_at",
"type_info": {
"type": "Timestamp",
"flags": "BINARY",
"max_size": 19
}
},
{
"ordinal": 7,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP",
"max_size": 19
}
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
true,
true,
false,
false
]
},
"hash": "944eb40633e943a75244dee639fe6efb16919aff7172189c81240cb12462ae58"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE Users SET email = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "9be7f66630e64787e55946dff428d28035747b66e57260bc9cd4634a71a037a6"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "DELETE FROM Users WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "9fa86328c40ce0469f755efb4876010092b7bc9f240a5d43dc69f9d0b1b5b7ce"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE Packages SET url = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "b5814b93236d587957a103e61726b0b9ae811ba6bff0617871e76de3ef0ff662"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE Packages SET description = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "c1abf048d65d421717f20343bb0ef4fcd78f8571cfe2347c147124763bd17491"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "DELETE FROM Users WHERE email = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "c2b00adbcb3c35a6ffa6b2bce08a738a9b3cd1ca4aa4c843909c7e14f7ef3e06"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE Users SET last_used = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "cc8f7e13c6aedf6aa4d6d4fc39db7aa98b84baf911e7f779641c1dc514c676cd"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE Users SET created_at = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "cf79e2f6038dddd055d535d2c41dd8dccd1a4e6a763963590c904c25abf33137"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE PackageBases SET updated_at = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "d289747c7c7fba86e2b66174e2d1546f10e8213d36b6b3cd25016f829e9d731b"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "DELETE FROM Packages WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "d474dd848d0ef8832afd4d1302fa562a3c4a4569032e8636d664043b5dc96661"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "INSERT INTO Users (name, email, password, last_used, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "daf98e6f1013c4993f7329f6fa690e92bccd89d1ff90131719c40626088dabd1"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE Packages SET name = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "e8ee44281a87c6e7147332dd5548971cb804a1ab1edcdae8bf009ac39059c2bb"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "DELETE FROM Packages WHERE name = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "f4963ad77bcbc0af4fc929f1f66b7ee842c26c44da32ae9bbbc06c466a908ccf"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "UPDATE Users SET name = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "f656bd1abb82c10af4e0e21b4a04a364988f5329356282f2ae0098dbfcaec671"
}

17
src/data/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "data"
version = "0.1.0"
edition = "2024"
[dependencies]
derive_more = { version = "2.0.1", features = ["deref", "into"] }
futures = "0.3.31"
chrono = { version = "0.4.39", default-features = false, features = [
"std",
"now",
] }
sqlx = { version = "0.8.3", default-features = false, features = ["mysql", "macros", "chrono", "runtime-tokio"] }
# thiserror = "2.0.11"
# garde = { version = "0.22.0", features = ["email", "url", "derive"] }

2
src/data/src/adapter.rs Normal file
View File

@ -0,0 +1,2 @@
//! Specific implementations of [`crate::port`]s to plug into other parts of the application.
pub mod mysql;

View File

@ -0,0 +1,5 @@
//! `MySQL` adapters.
pub mod base;
pub mod package;
pub mod search;
pub mod user;

View File

@ -0,0 +1,105 @@
use crate::Result;
use crate::port::base::{Base, BaseRepository, Field, New};
use chrono::Utc;
use sqlx::{Executor, MySql};
pub struct BaseAdapter;
impl<E> BaseRepository<E> for BaseAdapter
where
E: Send,
for<'a> &'a E: Executor<'a, Database = MySql>,
{
}
impl<E> crate::port::Crud<E> for BaseAdapter
where
E: Send,
for<'a> &'a E: Executor<'a, Database = MySql>,
{
type New = New;
type Unique = u64;
type Update = Field;
type Existing = Base;
async fn create(connection: &mut E, data: Self::New) -> Result<Self::Existing> {
let created_at = Utc::now();
let id = sqlx::query!(
"INSERT INTO PackageBases (name, description, created_at, updated_at) VALUES (?, ?, ?, ?)",
data.name.as_str(),
data.description.as_ref(),
created_at, created_at,
)
.execute(&*connection)
.await?
.last_insert_id();
Ok(Self::Existing {
id,
name: data.name.into(),
description: data.description.into(),
created_at,
updated_at: created_at,
})
}
async fn read(connection: &E, data: Self::Unique) -> Result<Option<Self::Existing>> {
Ok(
sqlx::query_as!(Base, "SELECT * FROM PackageBases WHERE id = ?", data)
.fetch_optional(connection)
.await?,
)
}
async fn update(
connection: &mut E,
existing: &mut Self::Existing,
data: Self::Update,
) -> Result {
match &data {
Field::Name(name) => {
sqlx::query!(
"UPDATE PackageBases SET name = ? WHERE id = ?",
name.as_str(),
existing.id
)
}
Field::Description(description) => {
sqlx::query!(
"UPDATE PackageBases SET description = ? WHERE id = ?",
description.as_ref(),
existing.id
)
}
Field::CreatedAt(date_time) => sqlx::query!(
"UPDATE PackageBases SET created_at = ? WHERE id = ?",
date_time,
existing.id
),
Field::UpdatedAt(date_time) => sqlx::query!(
"UPDATE PackageBases SET updated_at = ? WHERE id = ?",
date_time,
existing.id
),
}
.execute(&*connection)
.await?;
match data {
Field::Name(s) => existing.name = s.into(),
Field::Description(o) => existing.description = o.into(),
Field::CreatedAt(date_time) => existing.created_at = date_time,
Field::UpdatedAt(date_time) => existing.updated_at = date_time,
}
Ok(())
}
async fn delete(connection: &mut E, data: Self::Unique) -> Result {
sqlx::query!("DELETE FROM PackageBases WHERE id = ?", data)
.execute(&*connection)
.await?;
Ok(())
}
}

View File

@ -0,0 +1,162 @@
use crate::Result;
use crate::port::package::{Field, New, Package, PackageRepository, Unique};
use chrono::Utc;
use sqlx::{Executor, MySql};
pub struct PackageAdapter;
impl<E> PackageRepository<E> for PackageAdapter
where
E: Send,
for<'a> &'a E: Executor<'a, Database = MySql>,
{
}
impl<E> crate::port::Crud<E> for PackageAdapter
where
E: Send,
for<'a> &'a E: Executor<'a, Database = MySql>,
{
type New = New;
type Update = Field;
type Unique = Unique;
type Existing = Package;
async fn create(connection: &mut E, data: Self::New) -> Result<Self::Existing> {
let created_at = Utc::now();
let id = sqlx::query!(
"INSERT INTO Packages \
(base, name, version, description, url, flagged_at, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
data.package_base.id,
data.name.as_str(),
data.version.as_str(),
data.description.as_ref(),
data.url.as_ref(),
data.flagged_at,
created_at,
created_at,
)
.execute(&*connection)
.await?
.last_insert_id();
Ok(Self::Existing {
id,
base: data.package_base.id,
name: data.name.into(),
version: data.version.into(),
description: data.description.into(),
url: data.url.into(),
flagged_at: data.flagged_at,
created_at,
updated_at: created_at,
})
}
async fn read(connection: &E, data: Self::Unique) -> Result<Option<Self::Existing>> {
Ok(match data {
Unique::Id(id) => {
sqlx::query_as!(Package, "SELECT * FROM Packages WHERE id = ?", id)
.fetch_optional(connection)
.await
}
Unique::Name(name) => {
sqlx::query_as!(
Package,
"SELECT * FROM Packages WHERE name = ?",
name.as_str()
)
.fetch_optional(connection)
.await
}
}?)
}
async fn update(
connection: &mut E,
existing: &mut Self::Existing,
data: Self::Update,
) -> Result {
match &data {
Field::Name(name) => {
sqlx::query!(
"UPDATE Packages SET name = ? WHERE id = ?",
name.as_str(),
existing.id
)
}
Field::PackageBase(package_base) => {
sqlx::query!(
"UPDATE Packages SET base = ? WHERE id = ?",
package_base.id,
existing.id
)
}
Field::Version(version) => {
sqlx::query!(
"UPDATE Packages SET version = ? WHERE id = ?",
version.as_str(),
existing.id
)
}
Field::Description(description) => {
sqlx::query!(
"UPDATE Packages SET description = ? WHERE id = ?",
description.as_ref(),
existing.id
)
}
Field::Url(url) => {
sqlx::query!(
"UPDATE Packages SET url = ? WHERE id = ?",
url.as_ref(),
existing.id
)
}
Field::FlaggedAt(date_time) => sqlx::query!(
"UPDATE Packages SET flagged_at = ? WHERE id = ?",
date_time,
existing.id
),
Field::CreatedAt(date_time) => sqlx::query!(
"UPDATE Packages SET created_at = ? WHERE id = ?",
date_time,
existing.id
),
Field::UpdatedAt(date_time) => sqlx::query!(
"UPDATE Packages SET updated_at = ? WHERE id = ?",
date_time,
existing.id
),
}
.execute(&*connection)
.await?;
match data {
Field::Name(s) => existing.name = s.into(),
Field::PackageBase(s) => existing.base = s.id,
Field::Version(s) => existing.version = s.into(),
Field::Description(o) => existing.description = o.into(),
Field::Url(o) => existing.url = o.into(),
Field::FlaggedAt(date_time) => existing.flagged_at = date_time,
Field::CreatedAt(date_time) => existing.created_at = date_time,
Field::UpdatedAt(date_time) => existing.updated_at = date_time,
}
Ok(())
}
async fn delete(connection: &mut E, data: Self::Unique) -> Result {
match data {
Unique::Id(id) => sqlx::query!("DELETE FROM Packages WHERE id = ?", id),
Unique::Name(name) => {
sqlx::query!("DELETE FROM Packages WHERE name = ?", name.as_str())
}
}
.execute(&*connection)
.await?;
Ok(())
}
}

View File

@ -0,0 +1,152 @@
use crate::Result;
use crate::port::search::{Data, Entry, Mode, Order, SearchRepository};
// use chrono::Utc;
use futures::TryStreamExt;
use sqlx::{Executor, MySql, QueryBuilder, Row};
pub struct SearchAdapter;
impl<E> SearchRepository<E> for SearchAdapter
where
E: Send,
for<'a> &'a E: Executor<'a, Database = MySql>,
{
async fn search(connection: &E, data: Data) -> Result<Vec<Entry>> {
let mut builder = QueryBuilder::new(
"SELECT \
p.id, p.name, p.version, p.url, p.description, \
p.updated_at, p.created_at, \
pb.id AS base_id, pb.name AS base_name, \
( \
SELECT COUNT(DISTINCT pbur.user) \
FROM PackageBaseUserRoles pbur \
WHERE pbur.base = pb.id AND pbur.role = 3 \
) AS maintainers_num \
FROM \
Packages p \
JOIN \
PackageBases pb ON p.base = pb.id ",
);
let mut push_search = |cond, param| {
builder.push(format_args!(
" {cond} {param} {} ",
if data.exact { "=" } else { "LIKE" }
));
builder.push_bind(if data.exact {
data.search.to_string()
} else {
format!("%{}%", data.search.as_str())
});
};
let join_user = " JOIN PackageBaseUserRoles pbur ON pb.id = pbur.base \
JOIN Users u ON pbur.user = u.id WHERE ";
match data.mode {
Mode::Url => push_search("WHERE", "p.url"),
Mode::Name => push_search("WHERE", "p.name"),
Mode::PackageBase => push_search("WHERE", "pb.name"),
Mode::Description => push_search("WHERE", "p.description"),
Mode::BaseDescription => push_search("WHERE", "pb.description"),
Mode::NameAndDescription => {
// WHERE (p.name LIKE '%search_term%' OR p.description LIKE '%search_term%')
builder.push(" WHERE p.name LIKE ");
builder.push_bind(format!("%{}%", data.search.as_str()));
builder.push(" OR p.description LIKE ");
builder.push_bind(format!("%{}%", data.search.as_str()));
}
Mode::User => {
push_search(
"WHERE EXISTS ( \
SELECT 1 \
FROM PackageBaseUserRoles pbur \
JOIN Users u ON pbur.user = u.id \
WHERE pbur.base = pb.id AND",
"u.name",
);
builder.push(" ) ");
}
Mode::Flagger => {
push_search(join_user, "u.name");
builder.push(" AND pbur.role = 4 ");
} // 4
Mode::Packager => {
push_search(join_user, "u.name");
builder.push(" AND pbur.role = 2 ");
} // 2
Mode::Submitter => {
push_search(join_user, "u.name");
builder.push(" AND pbur.role = 1 ");
} // 1
Mode::Maintainer => {
push_search(join_user, "u.name");
builder.push(" AND pbur.role = 3 ");
} // 3
}
builder.push(format_args!(
" ORDER BY {} {} LIMIT {};",
match data.order {
Order::Name => "p.name",
Order::Version => "p.version",
Order::BaseName => "pb.name",
Order::UpdatedAt => "p.updated_at",
Order::CreatedAt => "p.created_at",
},
if data.ascending { "ASC" } else { "DESC" },
data.limit
));
let mut entries = Vec::new();
let mut rows = builder.build().fetch(connection);
while let Some(row) = rows.try_next().await? {
entries.push(Entry {
id: row.try_get("id")?,
name: row.try_get("name")?,
version: row.try_get("version")?,
base_id: row.try_get("base_id")?,
base_name: row.try_get("base_name")?,
url: row.try_get("url")?,
description: row.try_get("description")?,
// submitter_id: row.try_get("submitter_id")?,
// submitter_name: row.try_get("submitter_name")?,
updated_at: row.try_get("updated_at")?,
created_at: row.try_get("created_at")?,
});
}
Ok(entries)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Validation;
use crate::port::search::Search;
use sqlx::MySqlPool;
#[sqlx::test]
async fn search() -> crate::Result {
let pool = MySqlPool::connect_lazy(
&std::env::var("DATABASE_URL")
.expect("environment variable `DATABASE_URL` should be set"),
)?;
let data = Data {
mode: Mode::NameAndDescription,
order: Order::UpdatedAt,
search: Search::new("f").map_err(|e| e.1)?,
limit: 50,
exact: true,
ascending: false,
};
SearchAdapter::search(&pool, data).await?;
Ok(())
}
}

View File

@ -0,0 +1,146 @@
use crate::Result;
use crate::port::user::{Field, New, Unique, User, UserRepository};
use chrono::Utc;
use sqlx::{Executor, MySql};
pub struct UserAdapter;
impl<E> UserRepository<E> for UserAdapter
where
E: Send,
for<'a> &'a E: Executor<'a, Database = MySql>,
{
}
impl<E> crate::port::Crud<E> for UserAdapter
where
E: Send,
for<'a> &'a E: Executor<'a, Database = MySql>,
{
type New = New;
type Update = Field;
type Unique = Unique;
type Existing = User;
async fn create(connection: &mut E, data: Self::New) -> Result<Self::Existing> {
let created_at = Utc::now();
let id = sqlx::query!(
"INSERT INTO Users (name, email, password, last_used, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
data.name.as_str(),
data.email.as_str(),
data.password.as_str(),
data.last_used,
created_at,
created_at,
)
.execute(&*connection)
.await?
.last_insert_id();
Ok(Self::Existing {
id,
name: data.name.into(),
email: data.email.into(),
password: data.password.into(),
last_used: data.last_used,
created_at,
updated_at: created_at,
})
}
async fn read(connection: &E, data: Self::Unique) -> Result<Option<Self::Existing>> {
Ok(match data {
Unique::Id(id) => {
sqlx::query_as!(User, "SELECT * FROM Users WHERE id = ?", id)
.fetch_optional(connection)
.await
}
Unique::Name(name) => {
sqlx::query_as!(User, "SELECT * FROM Users WHERE name = ?", name.as_str())
.fetch_optional(connection)
.await
}
Unique::Email(email) => {
sqlx::query_as!(User, "SELECT * FROM Users WHERE email = ?", email.as_str())
.fetch_optional(connection)
.await
}
}?)
}
async fn update(
connection: &mut E,
existing: &mut Self::Existing,
data: Self::Update,
) -> Result {
match &data {
Field::Name(name) => {
sqlx::query!(
"UPDATE Users SET name = ? WHERE id = ?",
name.as_str(),
existing.id
)
}
Field::Email(email) => {
sqlx::query!(
"UPDATE Users SET email = ? WHERE id = ?",
email.as_str(),
existing.id
)
}
Field::Password(password) => {
sqlx::query!(
"UPDATE Users SET password = ? WHERE id = ?",
password.as_str(),
existing.id
)
}
Field::LastUsed(date_time) => {
sqlx::query!(
"UPDATE Users SET last_used = ? WHERE id = ?",
date_time,
existing.id
)
}
Field::CreatedAt(date_time) => sqlx::query!(
"UPDATE Users SET created_at = ? WHERE id = ?",
date_time,
existing.id
),
Field::UpdatedAt(date_time) => sqlx::query!(
"UPDATE Users SET updated_at = ? WHERE id = ?",
date_time,
existing.id
),
}
.execute(&*connection)
.await?;
match data {
Field::Name(valid) => existing.name = valid.into(),
Field::Email(valid) => existing.email = valid.into(),
Field::Password(valid) => existing.password = valid.into(),
Field::LastUsed(date_time) => existing.last_used = date_time,
Field::CreatedAt(date_time) => existing.created_at = date_time,
Field::UpdatedAt(date_time) => existing.updated_at = date_time,
}
Ok(())
}
async fn delete(connection: &mut E, data: Self::Unique) -> Result {
match data {
Unique::Id(id) => sqlx::query!("DELETE FROM Users WHERE id = ?", id),
Unique::Name(name) => {
sqlx::query!("DELETE FROM Users WHERE name = ?", name.as_str())
}
Unique::Email(email) => {
sqlx::query!("DELETE FROM Users WHERE email = ?", email.as_str())
}
}
.execute(&*connection)
.await?;
Ok(())
}
}

43
src/data/src/atomic.rs Normal file
View File

@ -0,0 +1,43 @@
//! Unify transaction management for established connections.
use crate::Result;
pub trait Atomic {
type Transaction<'a>;
fn start_transaction(&mut self) -> impl Future<Output = Result<Self::Transaction<'_>>> + Send;
fn abort_transaction(transaction: Self::Transaction<'_>)
-> impl Future<Output = Result> + Send;
fn commit_transaction(
transaction: Self::Transaction<'_>,
) -> impl Future<Output = Result> + Send;
}
use sqlx::Connection;
impl Atomic for sqlx::MySqlPool {
type Transaction<'a> = sqlx::MySqlTransaction<'a>;
async fn start_transaction(&mut self) -> Result<Self::Transaction<'_>> {
self.begin().await.map_err(Box::from)
}
async fn abort_transaction(transaction: Self::Transaction<'_>) -> Result {
transaction.rollback().await.map_err(Box::from)
}
async fn commit_transaction(transaction: Self::Transaction<'_>) -> Result {
transaction.commit().await.map_err(Box::from)
}
}
impl Atomic for sqlx::MySqlConnection {
type Transaction<'a> = sqlx::MySqlTransaction<'a>;
async fn start_transaction(&mut self) -> Result<Self::Transaction<'_>> {
self.begin().await.map_err(Box::from)
}
async fn abort_transaction(transaction: Self::Transaction<'_>) -> Result {
transaction.rollback().await.map_err(Box::from)
}
async fn commit_transaction(transaction: Self::Transaction<'_>) -> Result {
transaction.commit().await.map_err(Box::from)
}
}

53
src/data/src/connect.rs Normal file
View File

@ -0,0 +1,53 @@
//! Driver to manage a connection which is passed to adapters.
use crate::Result;
pub trait Connect {
type Connection;
fn open_connection(&self) -> impl Future<Output = Result<Self::Connection>> + Send;
fn close_connection(connection: Self::Connection) -> impl Future<Output = Result> + Send;
}
use sqlx::Connection;
pub use sqlx::MySqlConnection as SqlxConnection;
pub use sqlx::MySqlPool as SqlxPool;
#[derive(Clone)]
pub struct MySqlPool {
pool: SqlxPool,
}
impl MySqlPool {
pub const fn new(pool: SqlxPool) -> Self {
Self { pool }
}
}
impl Connect for MySqlPool {
type Connection = SqlxPool;
async fn open_connection(&self) -> Result<Self::Connection> {
Ok(self.pool.clone())
}
async fn close_connection(_: Self::Connection) -> Result {
Ok(())
}
}
pub struct MySqlConnection {
link: String,
}
impl MySqlConnection {
pub const fn new(link: String) -> Self {
Self { link }
}
}
impl Connect for MySqlConnection {
type Connection = SqlxConnection;
async fn open_connection(&self) -> Result<Self::Connection> {
SqlxConnection::connect(&self.link).await.map_err(Box::from)
}
async fn close_connection(connection: Self::Connection) -> Result {
connection.close().await?;
Ok(())
}
}

23
src/data/src/lib.rs Normal file
View File

@ -0,0 +1,23 @@
//! Data access for the application.
pub mod adapter;
pub mod atomic;
pub mod connect;
pub mod port;
// Don't want to handle errors for dynamic mess.
pub type BoxDynError = Box<dyn std::error::Error + Send + Sync + 'static>;
pub type Result<T = (), E = BoxDynError> = std::result::Result<T, E>;
pub use chrono::Utc;
pub use adapter::mysql::base::BaseAdapter as MySqlBaseAdapter;
pub use adapter::mysql::package::PackageAdapter as MySqlPackageAdapter;
pub use adapter::mysql::user::UserAdapter as MySqlUserAdapter;
pub use adapter::mysql::search::SearchAdapter as MySqlSearchAdapter;
pub use atomic::Atomic;
pub use connect::*;
pub use port::base::{Base, BaseRepository};
pub use port::package::{Package, PackageRepository};
pub use port::search::{Search, SearchRepository};
pub use port::user::{User, UserRepository};
pub use port::*;

91
src/data/src/port.rs Normal file
View File

@ -0,0 +1,91 @@
//! Low-level repository traits for unified data access.
//!
//! Very mild argument validation.
use crate::{BoxDynError, Result};
pub mod base;
pub mod package;
pub mod search;
pub mod user;
pub trait Crud<C> {
type New;
type Unique;
type Update;
type Existing;
fn create(
connection: &mut C,
data: Self::New,
) -> impl Future<Output = Result<Self::Existing>> + Send;
fn read(
connection: &C,
data: Self::Unique,
) -> impl Future<Output = Result<Option<Self::Existing>>> + Send;
fn update(
connection: &mut C,
existing: &mut Self::Existing,
data: Self::Update,
) -> impl Future<Output = Result> + Send;
fn delete(connection: &mut C, data: Self::Unique) -> impl Future<Output = Result> + Send;
}
pub trait CharLength {
fn length(&self) -> usize;
}
impl CharLength for &str {
fn length(&self) -> usize {
self.chars().count()
}
}
impl CharLength for String {
fn length(&self) -> usize {
self.chars().count()
}
}
impl CharLength for Option<String> {
fn length(&self) -> usize {
self.as_ref().map_or(0, CharLength::length)
}
}
trait Validatable {
type Inner: CharLength;
const MAX_LENGTH: usize;
fn encapsulate(value: Self::Inner) -> Self;
}
#[allow(private_bounds)] // don't expose the impl details
pub trait Validation<T>: Validatable
where
T: CharLength + Into<Self::Inner>,
{
fn valid(value: &T) -> Result<(), String> {
if value.length() > Self::MAX_LENGTH {
Err(format!(
"too long (length: {}, max length: {})",
value.length(),
Self::MAX_LENGTH
))
} else {
Ok(())
}
}
fn new(value: T) -> Result<Self, (T, BoxDynError)>
where
Self: Sized,
{
match Self::valid(&value) {
Ok(()) => Ok(Self::encapsulate(value.into())),
Err(e) => Err((value, e.into())),
}
}
}
impl<T, U> Validation<U> for T
where
T: Validatable,
U: CharLength + Into<T::Inner>,
{
}

70
src/data/src/port/base.rs Normal file
View File

@ -0,0 +1,70 @@
use super::Validatable;
use chrono::{DateTime, Utc};
use derive_more::{Deref, Into};
pub trait BaseRepository<C>:
super::Crud<C, New = New, Unique = u64, Update = Field, Existing = Base>
{
}
// #[derive(Deref, Into, Clone, Copy)]
// pub struct Id(pub(crate) u64);
#[derive(Clone, Deref, Into)]
pub struct Name(String);
impl Validatable for Name {
type Inner = String;
const MAX_LENGTH: usize = 127;
fn encapsulate(value: Self::Inner) -> Self {
Self(value)
}
}
#[derive(Clone, Deref, Into)]
pub struct Description(Option<String>);
impl Validatable for Description {
type Inner = Option<String>;
const MAX_LENGTH: usize = 510;
fn encapsulate(value: Self::Inner) -> Self {
Self(value)
}
}
pub enum Field {
Name(Name),
Description(Description),
CreatedAt(DateTime<Utc>),
UpdatedAt(DateTime<Utc>),
}
pub struct New {
pub name: Name,
pub description: Description,
}
pub struct Base {
pub(crate) id: u64,
pub(crate) name: String,
pub(crate) description: Option<String>,
pub(crate) created_at: DateTime<Utc>,
pub(crate) updated_at: DateTime<Utc>,
}
impl Base {
pub const fn id(&self) -> u64 {
self.id
}
pub const fn name(&self) -> &String {
&self.name
}
pub const fn description(&self) -> Option<&String> {
self.description.as_ref()
}
pub const fn created_at(&self) -> DateTime<Utc> {
self.created_at
}
pub const fn updated_at(&self) -> DateTime<Utc> {
self.updated_at
}
}

View File

@ -0,0 +1,117 @@
use super::Validatable;
use crate::Base;
use chrono::{DateTime, Utc};
use derive_more::{Deref, Into};
pub trait PackageRepository<C>:
super::Crud<C, New = New, Update = Field, Unique = Unique, Existing = Package>
{
}
#[derive(Clone, Deref, Into)]
pub struct Name(String);
impl Validatable for Name {
type Inner = String;
const MAX_LENGTH: usize = 127;
fn encapsulate(value: Self::Inner) -> Self {
Self(value)
}
}
#[derive(Clone, Deref, Into)]
pub struct Version(String);
impl Validatable for Version {
type Inner = String;
const MAX_LENGTH: usize = 127;
fn encapsulate(value: Self::Inner) -> Self {
Self(value)
}
}
#[derive(Clone, Deref, Into)]
pub struct Description(Option<String>);
impl Validatable for Description {
type Inner = Option<String>;
const MAX_LENGTH: usize = 255;
fn encapsulate(value: Self::Inner) -> Self {
Self(value)
}
}
#[derive(Clone, Deref, Into)]
pub struct Url(Option<String>);
impl Validatable for Url {
type Inner = Option<String>;
const MAX_LENGTH: usize = 510;
fn encapsulate(value: Self::Inner) -> Self {
Self(value)
}
}
pub enum Unique {
Id(u64),
Name(Name),
}
pub enum Field {
PackageBase(Base),
Name(Name),
Version(Version),
Description(Description),
Url(Url),
FlaggedAt(Option<DateTime<Utc>>),
CreatedAt(DateTime<Utc>),
UpdatedAt(DateTime<Utc>),
}
pub struct New {
pub package_base: Base,
pub name: Name,
pub version: Version,
pub description: Description,
pub url: Url,
pub flagged_at: Option<DateTime<Utc>>,
}
pub struct Package {
pub(crate) id: u64,
pub(crate) base: u64,
pub(crate) name: String,
pub(crate) version: String,
pub(crate) description: Option<String>,
pub(crate) url: Option<String>,
pub(crate) flagged_at: Option<DateTime<Utc>>,
pub(crate) created_at: DateTime<Utc>,
pub(crate) updated_at: DateTime<Utc>,
}
impl Package {
pub const fn id(&self) -> u64 {
self.id
}
pub const fn package_base(&self) -> u64 {
self.base
}
pub const fn name(&self) -> &String {
&self.name
}
pub const fn version(&self) -> &String {
&self.version
}
pub const fn description(&self) -> Option<&String> {
self.description.as_ref()
}
pub const fn url(&self) -> Option<&String> {
self.url.as_ref()
}
pub const fn flagged_at(&self) -> Option<DateTime<Utc>> {
self.flagged_at
}
pub const fn created_at(&self) -> DateTime<Utc> {
self.created_at
}
pub const fn updated_at(&self) -> DateTime<Utc> {
self.updated_at
}
}

View File

@ -0,0 +1,69 @@
use super::Validatable;
use crate::Result;
use chrono::{DateTime, Utc};
use derive_more::{Deref, Into};
pub trait SearchRepository<C> {
fn search(connection: &C, data: Data) -> impl Future<Output = Result<Vec<Entry>>> + Send;
}
#[derive(Clone, Deref, Into)]
pub struct Search(String);
impl Validatable for Search {
type Inner = String;
const MAX_LENGTH: usize = 255;
fn encapsulate(value: Self::Inner) -> Self {
Self(value)
}
}
pub struct Data {
pub mode: Mode,
pub order: Order,
pub search: Search,
pub limit: u16,
pub exact: bool,
pub ascending: bool,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct Entry {
pub id: u64,
pub name: Box<str>,
pub version: Box<str>,
pub base_id: u64,
pub base_name: Box<str>,
pub url: Option<Box<str>>,
pub description: Box<str>,
// pub submitter_id: u64,
// pub submitter_name: Box<str>,
pub updated_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Url,
Name,
PackageBase,
Description,
BaseDescription,
NameAndDescription,
User,
Flagger,
Packager,
Submitter,
Maintainer,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Order {
Name,
Version,
BaseName,
// Submitter,
UpdatedAt,
CreatedAt,
}

96
src/data/src/port/user.rs Normal file
View File

@ -0,0 +1,96 @@
use super::Validatable;
use chrono::{DateTime, Utc};
use derive_more::{Deref, Into};
pub trait UserRepository<C>:
super::Crud<C, New = New, Update = Field, Unique = Unique, Existing = User>
{
}
#[derive(Clone, Deref, Into)]
pub struct Name(String);
impl Validatable for Name {
type Inner = String;
const MAX_LENGTH: usize = 31;
fn encapsulate(value: Self::Inner) -> Self {
Self(value)
}
}
#[derive(Clone, Deref, Into)]
pub struct Email(String);
impl Validatable for Email {
type Inner = String;
const MAX_LENGTH: usize = 255;
fn encapsulate(value: Self::Inner) -> Self {
Self(value)
}
}
#[derive(Clone, Deref, Into)]
pub struct Password(String);
impl Validatable for Password {
type Inner = String;
const MAX_LENGTH: usize = 255;
fn encapsulate(value: Self::Inner) -> Self {
Self(value)
}
}
pub enum Unique {
Id(u64),
Name(Name),
Email(Email),
}
pub enum Field {
Name(Name),
Email(Email),
Password(Password),
LastUsed(Option<DateTime<Utc>>),
CreatedAt(DateTime<Utc>),
UpdatedAt(DateTime<Utc>),
}
pub struct New {
pub name: Name,
pub email: Email,
pub password: Password,
pub last_used: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct User {
pub(crate) id: u64,
pub(crate) name: String,
pub(crate) email: String,
pub(crate) password: String,
pub(crate) last_used: Option<DateTime<Utc>>,
pub(crate) created_at: DateTime<Utc>,
pub(crate) updated_at: DateTime<Utc>,
}
impl User {
pub const fn id(&self) -> u64 {
self.id
}
pub const fn name(&self) -> &String {
&self.name
}
pub const fn email(&self) -> &String {
&self.email
}
pub const fn password(&self) -> &String {
&self.password
}
pub const fn last_used(&self) -> Option<DateTime<Utc>> {
self.last_used
}
pub const fn created_at(&self) -> DateTime<Utc> {
self.created_at
}
pub const fn updated_at(&self) -> DateTime<Utc> {
self.updated_at
}
}

13
src/service/Cargo.toml Normal file
View File

@ -0,0 +1,13 @@
[package]
name = "service"
version = "0.1.0"
edition = "2024"
[dependencies]
thiserror = "2.0.11"
argon2 = { version = "0.5.3", features = ["std"] }
garde = { version = "0.22.0", features = ["email", "url", "derive"] }
derive_more = { version = "2.0.1", features = ["deref", "deref_mut", "into"] }
[dependencies.data]
path = "../data"

View File

@ -0,0 +1,9 @@
pub mod adapter;
pub mod contract;
pub mod repository;
pub mod service;
pub use adapter::*;
pub use contract::*;
pub use repository::*;
pub use service::*;

View File

@ -0,0 +1,71 @@
use super::{Authenticated, AuthenticationRepository, Get};
use data::user::{Field, New, User, UserRepository};
use data::{Connect, Result};
use std::marker::PhantomData;
pub struct AuthenticationAdapter<D, C, UR>
where
C: Send,
D: Connect<Connection = C> + Sync,
UR: UserRepository<C> + Sync,
{
driver: D,
// connection: Option<C>,
_user_repository: PhantomData<UR>,
}
impl<D, C, UR> AuthenticationAdapter<D, C, UR>
where
C: Send,
D: Connect<Connection = C> + Sync,
UR: UserRepository<C> + Sync,
{
pub const fn new(driver: D) -> Self {
Self {
driver,
// connection: None,
_user_repository: PhantomData,
}
}
// async fn connection(&mut self) -> Result<&C> {
// if let Some(ref c) = self.connection {
// Ok(c)
// } else {
// self.connection = Some(self.driver.open_connection().await?);
// self.connection().await
// }
// }
}
impl<D, C, UR> AuthenticationRepository for AuthenticationAdapter<D, C, UR>
where
C: Send, //+ Sync,
D: Connect<Connection = C> + Sync,
UR: UserRepository<C> + Sync,
{
async fn get_user(&self, get: Get) -> Result<Option<User>> {
let c = self.driver.open_connection().await?;
let user = UR::read(&c, get.into()).await?;
D::close_connection(c).await?;
Ok(user)
}
async fn create_user(&self, new: New) -> Result<User> {
let mut c = self.driver.open_connection().await?;
let user = UR::create(&mut c, new).await?;
D::close_connection(c).await?;
Ok(user)
}
async fn start_session(&self, mut user: User) -> Result<Authenticated> {
let mut c = self.driver.open_connection().await?;
UR::update(&mut c, &mut user, Field::LastUsed(Some(data::Utc::now()))).await?;
D::close_connection(c).await?;
Ok(Authenticated(user))
}
}

View File

@ -0,0 +1,155 @@
use super::Authenticated;
pub use data::Validation;
use data::{BoxDynError, user};
use derive_more::{Deref, Into};
use garde::Validate;
pub type Result<T = (), E = Error> = std::result::Result<T, E>;
pub trait AuthenticationContract: Send {
fn name_available(&self, name: Name) -> impl Future<Output = Result> + Send;
fn email_available(&self, email: Email) -> impl Future<Output = Result> + Send;
fn login(&self, data: LoginData) -> impl Future<Output = Result<Authenticated>> + Send;
fn register(
&mut self,
data: RegisterData,
) -> impl Future<Output = Result<Authenticated>> + Send;
}
pub struct LoginData {
pub login: Login,
pub password: Password,
}
pub struct RegisterData {
pub name: Name,
pub email: Email,
pub password: Password,
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
// Login
#[error("login was not found")]
LoginNotFound,
#[error("incorrect password")]
IncorrectPassword,
// Register
#[error("username is taken")]
NameExists,
#[error("email is already in use")]
EmailExists,
// Shared
#[error("invalid password: {0}")]
InvalidPassword(data::BoxDynError),
#[error("data source error: {0}")]
Repository(data::BoxDynError),
#[error(transparent)]
Other(data::BoxDynError),
}
pub type ReturnError<T = String> = (T, BoxDynError);
#[derive(Clone)]
pub enum Login {
Name(Name),
Email(Email),
}
impl AsRef<str> for Login {
fn as_ref(&self) -> &str {
match self {
Self::Name(name) => name.as_ref(),
Self::Email(email) => email.as_ref(),
}
}
}
impl TryFrom<String> for Login {
type Error = ReturnError;
fn try_from(value: String) -> Result<Self, Self::Error> {
let value = match Email::try_from(value) {
Ok(x) => return Ok(Self::Email(x)),
Err((v, _)) => v,
};
match Name::try_from(value) {
Ok(x) => Ok(Self::Name(x)),
Err((v, _)) => Err((v, "login is invalid".into())),
}
}
}
impl From<Login> for String {
fn from(val: Login) -> Self {
match val {
Login::Name(name) => name.0.into(),
Login::Email(email) => email.0.into(),
}
}
}
#[derive(Clone, Deref, Into)]
pub struct Name(user::Name);
impl AsRef<str> for Name {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}
impl TryFrom<String> for Name {
type Error = ReturnError;
fn try_from(value: String) -> Result<Self, Self::Error> {
#[derive(Validate)]
#[garde(transparent)]
struct Username<'a>(#[garde(alphanumeric, length(chars, min = 2, max = 31))] &'a str);
match Username(value.as_str()).validate() {
Ok(()) => (),
Err(e) => return Err((value, e.into())),
}
Ok(Self(user::Name::new(value)?))
}
}
#[derive(Clone, Deref, Into)]
pub struct Email(user::Email);
impl AsRef<str> for Email {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}
impl TryFrom<String> for Email {
type Error = ReturnError;
fn try_from(value: String) -> Result<Self, Self::Error> {
#[derive(Validate)]
#[garde(transparent)]
pub struct Email<'a>(#[garde(email, length(chars, max = 255))] &'a str);
match Email(value.as_str()).validate() {
Ok(()) => (),
Err(e) => return Err((value, e.into())),
}
Ok(Self(user::Email::new(value)?))
}
}
#[derive(Clone, Deref, Into)]
pub struct Password(String);
impl AsRef<str> for Password {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}
impl TryFrom<String> for Password {
type Error = ReturnError;
fn try_from(value: String) -> Result<Self, Self::Error> {
if value.chars().count() > 7 {
Ok(Self(value))
} else {
Err((value, "password must be longer than 7 characters".into()))
}
}
}

View File

@ -0,0 +1,26 @@
use data::Result;
use data::user::{Email, Name, New, Unique, User};
use derive_more::{Deref, DerefMut};
#[derive(Debug, Clone, Deref, DerefMut)]
pub struct Authenticated(pub(super) User);
pub trait AuthenticationRepository {
fn get_user(&self, get: Get) -> impl Future<Output = Result<Option<User>>> + Send;
fn create_user(&self, new: New) -> impl Future<Output = Result<User>> + Send;
fn start_session(&self, user: User) -> impl Future<Output = Result<Authenticated>> + Send;
}
pub enum Get {
Name(Name),
Email(Email),
}
impl From<Get> for Unique {
fn from(value: Get) -> Self {
match value {
Get::Name(s) => Self::Name(s),
Get::Email(s) => Self::Email(s),
}
}
}

View File

@ -0,0 +1,114 @@
use super::{
Authenticated, AuthenticationContract, AuthenticationRepository, Email, Error, Get, Login,
LoginData, Name, RegisterData, Result, Validation,
};
use argon2::{
Argon2,
password_hash::{
self, PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng,
},
};
impl From<password_hash::Error> for Error {
fn from(error: password_hash::Error) -> Self {
match error {
password_hash::Error::Password => Self::IncorrectPassword,
_ => Self::InvalidPassword(error.into()),
}
}
}
pub struct AuthenticationService<R>
where
R: AuthenticationRepository,
{
pub(crate) repository: R,
}
impl<R> AuthenticationService<R>
where
R: AuthenticationRepository,
{
pub const fn new(repository: R) -> Self {
Self { repository }
}
}
impl<R> AuthenticationContract for AuthenticationService<R>
where
R: AuthenticationRepository + Send + Sync,
{
async fn name_available(&self, name: Name) -> Result {
if self
.repository
.get_user(Get::Name(name.into()))
.await
.map_err(Error::Repository)?
.is_some()
{
return Err(Error::NameExists);
}
Ok(())
}
async fn email_available(&self, email: Email) -> Result {
if self
.repository
.get_user(Get::Email(email.into()))
.await
.map_err(Error::Repository)?
.is_some()
{
return Err(Error::EmailExists);
}
Ok(())
}
async fn login(&self, data: LoginData) -> Result<Authenticated> {
let user = match data.login {
Login::Name(name) => self.repository.get_user(Get::Name(name.into())),
Login::Email(email) => self.repository.get_user(Get::Email(email.into())),
}
.await
.map_err(Error::Repository)?
.ok_or(Error::LoginNotFound)?;
Argon2::default().verify_password(
data.password.as_bytes(),
&PasswordHash::new(user.password())?,
)?;
self.repository
.start_session(user)
.await
.map_err(Error::Repository)
}
async fn register(&mut self, data: RegisterData) -> Result<Authenticated> {
self.name_available(data.name.clone()).await?;
self.email_available(data.email.clone()).await?;
// Get PHC string ($argon2id$v=19$...)
let phc = Argon2::default()
.hash_password(data.password.as_bytes(), &SaltString::generate(&mut OsRng))?
.to_string();
let password = data::user::Password::new(phc)
.map_err(|(_, e)| Error::InvalidPassword(e))?;
let user = self
.repository
.create_user(data::user::New {
name: data.name.into(),
email: data.email.into(),
password,
last_used: None,
})
.await
.map_err(Error::Repository)?;
self.repository
.start_session(user)
.await
.map_err(Error::Repository)
}
}

8
src/service/src/lib.rs Normal file
View File

@ -0,0 +1,8 @@
pub mod authentication;
pub mod search;
pub use authentication::{
Authenticated, AuthenticationAdapter, AuthenticationContract, AuthenticationRepository,
AuthenticationService,
};
pub use search::{Search, SearchAdapter, SearchContract, SearchRepository, SearchService};

View File

@ -0,0 +1,9 @@
pub mod adapter;
pub mod contract;
pub mod repository;
pub mod service;
pub use adapter::*;
pub use contract::*;
pub use repository::*;
pub use service::*;

View File

@ -0,0 +1,43 @@
use data::search::*;
use data::{Connect, Result};
use std::marker::PhantomData;
pub struct SearchAdapter<D, C, UR>
where
C: Send,
D: Connect<Connection = C> + Sync,
UR: SearchRepository<C> + Sync,
{
driver: D,
_search_repository: PhantomData<UR>,
}
impl<D, C, UR> SearchAdapter<D, C, UR>
where
C: Send,
D: Connect<Connection = C> + Sync,
UR: SearchRepository<C> + Sync,
{
pub const fn new(driver: D) -> Self {
Self {
driver,
_search_repository: PhantomData,
}
}
}
impl<D, C, SR> super::SearchRepository for SearchAdapter<D, C, SR>
where
C: Send, //+ Sync,
D: Connect<Connection = C> + Sync,
SR: SearchRepository<C> + Sync,
{
async fn search(&self, data: Data) -> Result<Vec<Entry>> {
let c = self.driver.open_connection().await?;
let result = SR::search(&c, data).await?;
D::close_connection(c).await?;
Ok(result)
}
}

View File

@ -0,0 +1,60 @@
use data::{BoxDynError, search};
pub use data::{
Result, Validation,
search::{Mode, Order, Entry},
};
use derive_more::{Deref, Into};
use garde::Validate;
pub trait SearchContract: Send {
fn search(&self, data: Data) -> impl Future<Output = Result<Vec<Entry>>> + Send;
}
pub struct Data {
pub mode: Mode,
pub order: Order,
pub search: Search,
pub limit: u16,
pub exact: bool,
pub ascending: bool,
}
impl From<Data> for search::Data {
fn from(value: Data) -> Self {
Self {
mode: value.mode,
order: value.order,
search: value.search.into(),
limit: value.limit,
exact: value.exact,
ascending: value.ascending,
}
}
}
pub type ReturnError<T = String> = (T, BoxDynError);
#[derive(Clone, Deref, Into)]
pub struct Search(search::Search);
impl AsRef<str> for Search {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}
impl TryFrom<String> for Search {
type Error = ReturnError;
fn try_from(value: String) -> Result<Self, Self::Error> {
#[derive(Validate)]
#[garde(transparent)]
struct Check<'a>(#[garde(ascii, length(chars, min = 1, max = 255))] &'a str);
match Check(value.as_str()).validate() {
Ok(()) => (),
Err(e) => return Err((value, e.into())),
}
Ok(Self(search::Search::new(value)?))
}
}

View File

@ -0,0 +1,6 @@
use data::Result;
use data::search::{Data, Entry};
pub trait SearchRepository {
fn search(&self, data: Data) -> impl Future<Output = Result<Vec<Entry>>> + Send;
}

View File

@ -0,0 +1,27 @@
use super::{Data, Result, SearchContract, SearchRepository};
use data::search;
pub struct SearchService<R>
where
R: SearchRepository,
{
pub(crate) repository: R,
}
impl<R> SearchService<R>
where
R: SearchRepository,
{
pub const fn new(repository: R) -> Self {
Self { repository }
}
}
impl<R> SearchContract for SearchService<R>
where
R: SearchRepository + Send + Sync,
{
async fn search(&self, data: Data) -> Result<Vec<search::Entry>> {
self.repository.search(data.into()).await
}
}

83
src/src/authentication.rs Normal file
View File

@ -0,0 +1,83 @@
mod login;
mod register;
use login::Login;
use register::Register;
use service::{Authenticated, AuthenticationContract};
use iced::{Element, Task, futures::lock::Mutex};
use std::sync::Arc;
pub struct Authentication<S> {
login: Login<S>,
register: Register<S>,
screen: Screen,
}
enum Screen {
Login,
Register,
}
#[derive(Debug)]
pub enum Message {
Login(login::Message),
Register(register::Message),
}
pub enum Event {
Task(Task<Message>),
Authenticated(Authenticated),
}
impl From<Task<Message>> for Event {
fn from(value: Task<Message>) -> Self {
Self::Task(value)
}
}
impl<S: AuthenticationContract + 'static> Authentication<S> {
pub fn new(service: Arc<Mutex<S>>) -> Self {
Self {
login: Login::new(service.clone()),
register: Register::new(service),
screen: Screen::Login,
}
}
pub fn update(&mut self, message: Message) -> Option<Event> {
Some(match message {
Message::Login(message) => match self.login.update(message)? {
login::Event::SwitchToRegister => {
self.screen = Screen::Register;
return None;
}
login::Event::Task(task) => task.map(Message::Login).into(),
login::Event::Authenticated(x) => Event::Authenticated(x),
},
Message::Register(message) => match self.register.update(message)? {
register::Event::SwitchToLogin => {
self.screen = Screen::Login;
return None;
}
register::Event::Task(task) => task.map(Message::Register).into(),
register::Event::Authenticated(x) => Event::Authenticated(x),
},
})
}
pub fn view(&self) -> Element<Message> {
match self.screen {
Screen::Login => self.login.view().map(Message::Login),
Screen::Register => self.register.view().map(Message::Register),
}
}
pub fn title(&self) -> String {
match self.screen {
Screen::Login => self.login.title(),
Screen::Register => self.register.title(),
}
}
}

View File

@ -0,0 +1,181 @@
use crate::input::Input;
use crate::widget::centerbox;
use service::{
Authenticated, AuthenticationContract,
authentication::{self, Error, LoginData, Result},
};
use iced::futures::lock::Mutex;
use iced::widget::{Space, button, checkbox, column, container, row, text};
use iced::{Length, Task, padding};
use std::sync::Arc;
pub struct Login<S> {
login: Input<authentication::Login>,
password: Input<authentication::Password>,
show_password: bool,
state: State,
service: Arc<Mutex<S>>,
}
enum State {
None,
Requesting,
Success,
Error(String),
}
pub enum Event {
SwitchToRegister,
Task(Task<Message>),
Authenticated(Authenticated),
}
impl From<Task<Message>> for Event {
fn from(value: Task<Message>) -> Self {
Self::Task(value)
}
}
#[derive(Debug, Clone)]
pub enum Message {
LoginChanged(String),
PasswordChanged(String),
ShowPasswordToggled(bool),
LoginSubmitted,
PasswordSubmitted,
LoginPressed,
RegisterPressed,
RequestResult(Arc<Result<Authenticated>>),
}
impl<S: AuthenticationContract + 'static> Login<S> {
pub fn new(service: Arc<Mutex<S>>) -> Self {
Self {
login: Input::new("login_name"),
password: Input::new("login_password"),
show_password: false,
state: State::None,
service,
}
}
pub fn update(&mut self, message: Message) -> Option<Event> {
match message {
Message::LoginChanged(s) => self.login.update(s),
Message::PasswordChanged(s) => self.password.update(s),
Message::ShowPasswordToggled(b) => self.show_password = b,
Message::LoginSubmitted if self.login.critical() => (),
Message::LoginSubmitted => return Some(self.password.focus().into()),
Message::RegisterPressed => return Some(Event::SwitchToRegister),
Message::LoginPressed | Message::PasswordSubmitted => {
let login_data = LoginData {
login: match self.login.submit() {
Ok(x) => x,
Err(t) => return Some(t.into()),
},
password: match self.password.submit() {
Ok(x) => x,
Err(t) => return Some(t.into()),
},
};
self.state = State::Requesting;
let arc = self.service.clone();
return Some(
Task::perform(
async move {
let Some(service) = arc.try_lock() else {
return Err(Error::Other(
"other authentication request is being performed".into(),
));
};
service.login(login_data).await
},
|r| Message::RequestResult(Arc::new(r)),
)
.into(),
);
}
Message::RequestResult(r) => match &*r {
Ok(a) => {
self.state = State::Success;
return Some(Event::Authenticated(a.clone()));
}
Err(e) => {
self.state = State::None;
match e {
Error::LoginNotFound => self.login.set_warning(e),
Error::IncorrectPassword => self.password.set_warning(e),
Error::InvalidPassword(_) => self.password.set_error(e),
_ => self.state = State::Error(e.to_string()),
}
}
},
}
None
}
pub fn view(&self) -> iced::Element<Message> {
centerbox(
column![
container(text(self.title()).size(20))
.center_x(Length::Fill)
.padding(padding::bottom(10)),
self.login
.view("Email or Username")
.on_input(Message::LoginChanged)
.on_submit(Message::LoginSubmitted),
self.password
.view("Password")
.on_input(Message::PasswordChanged)
.on_submit(Message::PasswordSubmitted)
.secure(!self.show_password),
checkbox("Show password", self.show_password)
.on_toggle(Message::ShowPasswordToggled),
row![
button(text("Register").center().size(18))
.on_press(Message::RegisterPressed)
.style(button::secondary)
.width(Length::FillPortion(3))
.padding(10),
Space::with_width(Length::FillPortion(2)),
button(text("Login").center().size(18))
.on_press(Message::LoginPressed)
.style(button::primary)
.width(Length::FillPortion(3))
.padding(10)
]
.padding(padding::top(15)),
]
.width(Length::Fixed(250.))
.spacing(20),
)
}
pub fn title(&self) -> String {
let errors = [
self.login.error(),
self.password.error(),
self.login.warning(),
self.password.warning(),
];
let error = errors.into_iter().flatten().next();
match &self.state {
State::None => error.map_or_else(|| "Login".into(), Into::into),
State::Success => "Success".into(),
State::Requesting => "Requesting...".into(),
State::Error(e) => e.into(),
}
}
}

View File

@ -0,0 +1,234 @@
use crate::input::{self, Input, Value};
use crate::widget::centerbox;
use service::authentication::{self, Email, Name, Password, RegisterData};
use service::{
Authenticated, AuthenticationContract,
authentication::{Error, LoginData, Result},
};
use iced::futures::lock::Mutex;
use iced::widget::{Space, button, checkbox, column, container, row, text};
use iced::{Length, Task, padding};
use std::sync::Arc;
pub struct Register<S> {
name: Input<Name>,
email: Input<Email>,
password: Input<Password>,
repeat: Input<String>,
show_password: bool,
state: State,
service: Arc<Mutex<S>>,
}
enum State {
None,
Success,
Requesting,
Error(String),
}
pub enum Event {
SwitchToLogin,
Task(Task<Message>),
Authenticated(Authenticated),
}
impl From<Task<Message>> for Event {
fn from(value: Task<Message>) -> Self {
Self::Task(value)
}
}
#[derive(Debug, Clone)]
pub enum Message {
NameChanged(String),
EmailChanged(String),
PasswordChanged(String),
RepeatChanged(String),
ShowPasswordToggled(bool),
EmailSubmitted,
NameSubmitted,
PasswordSubmitted,
RepeatSubmitted,
RegisterPressed,
LoginPressed,
RequestResult(Arc<Result<Authenticated>>),
}
impl<S: AuthenticationContract + 'static> Register<S> {
pub fn new(service: Arc<Mutex<S>>) -> Self {
Self {
name: Input::new("register_name"),
email: Input::new("register_email"),
password: Input::new("register_password"),
repeat: Input::new("register_repeat"),
show_password: false,
state: State::None,
service,
}
}
fn check_passwords(&mut self) {
if self.password.as_ref() == self.repeat.as_ref() {
self.repeat
.set_value(Value::Valid(self.repeat.as_ref().to_string()));
} else {
self.repeat.set_error(&"passwords are different");
}
}
pub fn update(&mut self, message: Message) -> Option<Event> {
match message {
Message::NameChanged(s) => self.name.update(s),
Message::EmailChanged(s) => self.email.update(s),
Message::PasswordChanged(s) => {
self.password.update(s);
self.check_passwords();
}
Message::RepeatChanged(s) => {
self.repeat.set_value(Value::Valid(s));
self.check_passwords();
}
Message::ShowPasswordToggled(b) => self.show_password = b,
Message::NameSubmitted if self.name.critical() => (),
Message::NameSubmitted => return Some(self.email.focus().into()),
Message::EmailSubmitted if self.email.critical() => (),
Message::EmailSubmitted => return Some(self.password.focus().into()),
Message::PasswordSubmitted if self.password.critical() => (),
Message::PasswordSubmitted => return Some(self.repeat.focus().into()),
Message::RegisterPressed | Message::RepeatSubmitted
if self.repeat.error().is_some() =>
{
return Some(self.repeat.focus().into());
}
Message::RegisterPressed | Message::RepeatSubmitted => {
let register_data = RegisterData {
name: match self.name.submit() {
Ok(x) => x,
Err(t) => return Some(t.into()),
},
email: match self.email.submit() {
Ok(x) => x,
Err(t) => return Some(t.into()),
},
password: match self.password.submit() {
Ok(x) => x,
Err(t) => return Some(t.into()),
},
};
self.state = State::Requesting;
let arc = self.service.clone();
return Some(
Task::perform(
async move {
let Some(mut service) = arc.try_lock() else {
return Err(Error::Other(
"other authentication request is being performed".into(),
));
};
service.register(register_data).await
},
|r| Message::RequestResult(Arc::new(r)),
)
.into(),
);
}
Message::LoginPressed => return Some(Event::SwitchToLogin),
Message::RequestResult(r) => match &*r {
Ok(a) => {
self.state = State::Success;
return Some(Event::Authenticated(a.clone()));
}
Err(e) => {
self.state = State::None;
match e {
Error::NameExists => self.name.set_warning(e),
Error::EmailExists => self.email.set_warning(e),
Error::IncorrectPassword => self.password.set_warning(e),
Error::InvalidPassword(_) => self.password.set_error(e),
_ => self.state = State::Error(e.to_string()),
}
}
},
}
None
}
pub fn view(&self) -> iced::Element<Message> {
centerbox(
column![
container(text(self.title()).size(20))
.center_x(Length::Fill)
.padding(padding::bottom(10)),
self.name
.view("Username")
.on_input(Message::NameChanged)
.on_submit(Message::NameSubmitted),
self.email
.view("Email")
.on_input(Message::EmailChanged)
.on_submit(Message::EmailSubmitted),
self.password
.view("Password")
.on_input(Message::PasswordChanged)
.on_submit(Message::PasswordSubmitted)
.secure(!self.show_password),
self.repeat
.view("Repeat Password")
.on_input(Message::RepeatChanged)
.on_submit(Message::RepeatSubmitted)
.secure(!self.show_password),
checkbox("Show password", self.show_password)
.on_toggle(Message::ShowPasswordToggled),
row![
button(text("Login").center().size(18))
.on_press(Message::LoginPressed)
.style(button::secondary)
.width(Length::FillPortion(3))
.padding(10),
Space::with_width(Length::FillPortion(2)),
button(text("Register").center().size(18))
.on_press(Message::RegisterPressed)
.style(button::primary)
.width(Length::FillPortion(3))
.padding(10),
]
.padding(padding::top(15)),
]
.width(Length::Fixed(250.))
.spacing(20),
)
}
pub fn title(&self) -> String {
let errors = [
self.name.error(),
self.email.error(),
self.password.error(),
self.repeat.error(),
self.name.warning(),
self.email.warning(),
self.password.warning(),
self.repeat.warning(),
];
let error = errors.into_iter().flatten().next();
match &self.state {
State::None => error.map_or_else(|| "Register".into(), Into::into),
State::Success => "Success".into(),
State::Requesting => "Requesting...".into(),
State::Error(e) => e.into(),
}
}
}

171
src/src/input.rs Normal file
View File

@ -0,0 +1,171 @@
use crate::widget::text_input::{error, success, warning};
use iced::widget::{TextInput, text_input, text_input::default};
/// A smarter [`text_input`].to avoid boilerplate.
pub struct Input<T> {
id: &'static str,
value: Value<T>,
warning: Option<String>,
}
// use std::ops::Deref;
// impl<T> Deref for Input<T> {
// type Target = Value<T>;
// fn deref(&self) -> &Self::Target {
// &self.value
// }
// }
impl<T: AsRef<str>> AsRef<str> for Input<T> {
fn as_ref(&self) -> &str {
self.value.as_ref()
}
}
impl<T> Input<T> {
pub const fn new(id: &'static str) -> Self {
Self {
id,
value: Value::None,
warning: None,
}
}
pub fn focus<Message>(&self) -> iced::Task<Message> {
iced::widget::text_input::focus(self.id)
}
pub fn warning(&self) -> Option<&str> {
self.warning.as_ref().map(AsRef::as_ref)
}
pub fn error(&self) -> Option<&str> {
match &self.value {
Value::Invalid { error, .. } => Some(error.as_ref()),
_ => None,
}
}
pub fn set_value(&mut self, value: Value<T>) {
self.value = value;
}
pub fn set_warning(&mut self, value: &impl ToString) {
self.warning = Some(value.to_string());
}
}
impl<T: AsRef<str>> Input<T> {
pub fn set_error(&mut self, value: &impl ToString) {
self.value.set_error(value.to_string());
}
pub fn view<Message: Clone>(&self, placeholder: &str) -> TextInput<Message> {
text_input(placeholder, self.value.as_ref())
.id(self.id)
.padding(12)
.style(match self.value {
Value::Invalid { .. } => error,
Value::None | Value::Valid(_) if self.warning.is_some() => warning,
Value::Valid(_) => success,
Value::None => default,
})
}
}
impl<T, E> Input<T>
where
E: ToString,
T: TryFrom<String, Error = (String, E)>,
{
pub fn update(&mut self, value: String) {
self.value.update(value);
self.warning = None;
}
pub fn value(&mut self) -> Result<&T, &str> {
match self.value {
Value::None => {
self.value.update(String::new());
self.value()
}
Value::Valid(ref x) => Ok(x),
Value::Invalid { ref error, .. } => Err(error),
}
}
pub fn submittable(&mut self) -> bool {
self.value().is_ok()
}
pub fn critical(&mut self) -> bool {
self.value().is_err()
}
}
impl<T, E> Input<T>
where
E: ToString,
T: TryFrom<String, Error = (String, E)> + Clone,
{
pub fn submit<Message>(&mut self) -> Result<T, iced::Task<Message>> {
match self.value() {
Ok(x) => Ok(x.clone()),
Err(_) => Err(self.focus()),
}
}
}
#[derive(Default)]
pub enum Value<T> {
#[default]
None,
Valid(T),
Invalid {
value: String,
error: String,
},
}
impl<T: AsRef<str>> AsRef<str> for Value<T> {
fn as_ref(&self) -> &str {
match self {
Self::None => "",
Self::Valid(x) => x.as_ref(),
Self::Invalid { value, .. } => value.as_ref(),
}
}
}
impl<T: AsRef<str>> Value<T> {
fn set_error(&mut self, error: String) {
match self {
Self::None => {
*self = Self::Invalid {
value: String::new(),
error,
}
}
Self::Valid(x) => {
*self = Self::Invalid {
value: x.as_ref().to_string(),
error,
}
}
Self::Invalid { error: e, .. } => *e = error,
}
}
}
impl<T, E> Value<T>
where
E: ToString,
T: TryFrom<String, Error = (String, E)>,
{
fn update(&mut self, value: String) {
*self = match T::try_from(value) {
Ok(x) => Self::Valid(x),
Err((s, e)) => Self::Invalid {
value: s,
error: e.to_string(),
},
};
}
}

208
src/src/main.rs Normal file
View File

@ -0,0 +1,208 @@
mod authentication;
mod input;
mod search;
//mod statistics;
mod widget;
use std::sync::Arc;
use crate::authentication::Authentication;
use crate::search::Search;
//use crate::statistics::Statistics;
use data::{MySqlPool, MySqlSearchAdapter, MySqlUserAdapter, SqlxPool};
use iced::{
Element, Subscription, Task, Theme,
futures::lock::Mutex,
widget::{center, row},
window,
};
use service::{
Authenticated, AuthenticationAdapter, AuthenticationService, SearchAdapter, SearchService,
};
struct Repository {
scale_factor: f64,
main_id: window::Id,
screen: Screen,
authenticated: Option<Authenticated>,
search: Search<SearchService<SearchAdapter<MySqlPool, SqlxPool, MySqlSearchAdapter>>>,
authentication: Authentication<
AuthenticationService<AuthenticationAdapter<MySqlPool, SqlxPool, MySqlUserAdapter>>,
>,
}
#[derive(Default)]
enum Screen {
Search,
// Statistics,
#[default]
Authentication,
}
#[derive(Debug)]
enum Message {
ScaleUp,
ScaleDown,
WindowOpened(window::Id),
WindowClosed(window::Id),
Search(search::Message),
Authentecation(authentication::Message),
}
impl Repository {
fn new() -> (Self, Task<Message>) {
let (main_id, open_task) = window::open(window::Settings::default());
// let (main_window, main_window_task) = MainWindow::new();
let pool = MySqlPool::new(
SqlxPool::connect_lazy(
&std::env::var("DATABASE_URL")
.expect("environment variable `DATABASE_URL` should be set"),
)
.unwrap(),
);
let auth_service = Arc::new(Mutex::new(AuthenticationService::new(
AuthenticationAdapter::new(pool.clone()),
)));
let search_service = Arc::new(Mutex::new(SearchService::new(SearchAdapter::new(
pool.clone(),
))));
(
Self {
main_id,
scale_factor: 1.4,
screen: Screen::default(),
authenticated: None,
search: Search::new(search_service),
authentication: Authentication::new(auth_service),
},
Task::batch([
open_task.map(Message::WindowOpened),
// main_window_task.map(Message::MainWindow),
]),
)
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::ScaleUp => self.scale_factor = (self.scale_factor + 0.2).min(5.0),
Message::ScaleDown => self.scale_factor = (self.scale_factor - 0.2).max(0.2),
Message::WindowOpened(id) => {
log!("Window opened: {id}");
return iced::widget::focus_next();
}
Message::WindowClosed(id) => {
log!("Window closed: {id}");
if id == self.main_id {
return iced::exit();
}
}
Message::Authentecation(message) => {
if let Some(event) = self.authentication.update(message) {
match event {
authentication::Event::Task(task) => {
return task.map(Message::Authentecation);
}
authentication::Event::Authenticated(authenticated) => {
log!("authenticated as {:#?}", *authenticated);
self.authenticated = Some(authenticated);
self.screen = Screen::Search;
}
}
}
}
Message::Search(m) => {
if let Some(event) = self.search.update(m) {
match event {
search::Event::Task(task) => {
return task.map(Message::Search);
}
search::Event::OpenPackage(id) => {
log!("opening package {id}");
}
search::Event::OpenBase(id) => {
log!("opening package base {id}");
}
search::Event::OpenURL(url) => {
log!("opening url {url}");
match open::that(url.as_ref()) {
Ok(()) => {
log!("opened url {url}");
}
Err(e) => {
log!("can't open url \"{url}\": {e}");
}
}
}
}
}
}
}
Task::none()
}
fn view(&self, id: window::Id) -> Element<Message> {
if self.main_id == id {
match self.screen {
Screen::Search => self.search.view().map(Message::Search),
Screen::Authentication => self.authentication.view().map(Message::Authentecation),
}
} else {
center(row!["This window is unknown.", "It may be closed."]).into()
}
}
fn title(&self, _: window::Id) -> String {
// "Repository".into()
match self.screen {
Screen::Search => self.search.title(),
Screen::Authentication => self.authentication.title(),
}
}
fn subscription(&self) -> Subscription<Message> {
use iced::keyboard::{self, Key, Modifiers};
let hotkeys = keyboard::on_key_press(|key, modifiers| match (modifiers, key) {
(Modifiers::CTRL, Key::Character(c)) if c == "-" => Some(Message::ScaleDown),
(Modifiers::CTRL, Key::Character(c)) if c == "=" || c == "+" => Some(Message::ScaleUp),
_ => None,
});
Subscription::batch([hotkeys, window::close_events().map(Message::WindowClosed)])
}
const fn scale_factor(&self, _: window::Id) -> f64 {
self.scale_factor
}
const fn theme(_: &Self, _: window::Id) -> Theme {
Theme::TokyoNight
}
}
#[macro_export]
macro_rules! log {
($($arg:tt)*) => {
#[cfg(debug_assertions)]
println!($($arg)*)
};
}
fn main() -> iced::Result {
iced::daemon(Repository::title, Repository::update, Repository::view)
.subscription(Repository::subscription)
.scale_factor(Repository::scale_factor)
.theme(Repository::theme)
.run_with(Repository::new)
}

341
src/src/search.rs Normal file
View File

@ -0,0 +1,341 @@
use crate::input::Input;
use crate::widget::{scroll, tip, url};
use iced::Length::Shrink;
use service::search::Data;
use service::{
SearchContract,
search::{self, Entry, Result},
};
use iced::widget::{Column, button, checkbox, column, container, lazy, pick_list, row, text};
use iced::{Element, Length::Fill, Task, futures::lock::Mutex};
use std::sync::Arc;
use strum::{Display, VariantArray};
pub struct Search<S> {
input: Input<search::Search>,
mode: Mode,
order: Order,
ascending: bool,
exact: bool,
limit: u8,
state: State,
service: Arc<Mutex<S>>,
}
#[derive(Default)]
enum State {
#[default]
None,
Searching,
// Aborted,
Table(Table),
Error(String),
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Display, VariantArray)]
pub enum Mode {
Url,
Name,
#[strum(to_string = "Package Base")]
PackageBase,
Description,
#[strum(to_string = "Base description")]
BaseDescription,
#[default]
#[strum(to_string = "Name and Description")]
NameAndDescription,
User,
Flagger,
Packager,
Submitter,
Maintainer,
}
impl From<Mode> for search::Mode {
fn from(value: Mode) -> Self {
match value {
Mode::Url => Self::Url,
Mode::Name => Self::Name,
Mode::PackageBase => Self::PackageBase,
Mode::Description => Self::Description,
Mode::BaseDescription => Self::BaseDescription,
Mode::NameAndDescription => Self::NameAndDescription,
Mode::User => Self::User,
Mode::Flagger => Self::Flagger,
Mode::Packager => Self::Packager,
Mode::Submitter => Self::Submitter,
Mode::Maintainer => Self::Maintainer,
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Display, VariantArray)]
pub enum Order {
Name,
Version,
#[strum(to_string = "Base Name")]
BaseName,
// Submitter,
#[default]
#[strum(to_string = "Last update")]
UpdatedAt,
#[strum(to_string = "Created time")]
CreatedAt,
}
impl From<Order> for search::Order {
fn from(value: Order) -> Self {
match value {
Order::Name => Self::Name,
Order::Version => Self::Version,
Order::BaseName => Self::BaseName,
Order::UpdatedAt => Self::UpdatedAt,
Order::CreatedAt => Self::CreatedAt,
}
}
}
#[derive(Debug, Clone)]
pub enum Message {
// Search bar
Reset,
Search,
SearchChanged(String),
ModePicked(Mode),
OrderPicked(Order),
AscendingToggled(bool),
ExactToggled(bool),
ShowEntriesPicked(u8),
// Table
PackagePressed(u64),
BasePressed(u64),
URLPressed(Box<str>),
RequestResult(Arc<Result<Vec<Entry>>>),
}
pub enum Event {
Task(Task<Message>),
OpenPackage(u64),
OpenBase(u64),
OpenURL(Box<str>),
}
impl From<Task<Message>> for Event {
fn from(value: Task<Message>) -> Self {
Self::Task(value)
}
}
#[derive(Debug, Hash)]
struct Table(Vec<Entry>);
impl Table {
pub fn view(&self) -> Element<'static, Message> {
let mut table: Vec<_> = [
"Package", // 0
"Version", // 1
"Base", // 2
"URL", // 3
"Description", // 4
"Last Updated", // 5
"Created", // 6
]
.into_iter()
.map(|s| {
let mut v = Vec::with_capacity(self.0.len());
v.push(s.into());
v.push("".into());
v
})
.collect();
for entry in &self.0 {
table[0].push(url(&entry.name, Message::PackagePressed(entry.id)));
table[1].push(text(entry.version.to_string()).into());
table[2].push(url(&entry.base_name, Message::BasePressed(entry.base_id)));
table[3].push(
entry
.url
.as_ref()
.map_or("-".into(), |s|
tip(
url(&"link", Message::URLPressed(s.clone())),
s.clone(),
tip::Position::Bottom,
),
),
);
table[4].push(text(entry.description.to_string()).into());
table[5].push(text(entry.updated_at.to_string()).into());
table[6].push(text(entry.created_at.to_string()).into());
// table[5].push(Element::from(column( entry .maintainers .iter() .map(|(id, s)| url(s, Message::UserPressed(*id))),)));
}
scroll(
row(table
.into_iter()
.map(|v| Column::from_vec(v).spacing(5).into()))
.spacing(20)
.padding(30),
)
}
}
impl<S: SearchContract + 'static> Search<S> {
pub fn new(service: Arc<Mutex<S>>) -> Self {
Self {
input: Input::new("search_input"),
mode: Mode::NameAndDescription,
order: Order::UpdatedAt,
ascending: false,
exact: false,
limit: 25,
state: State::default(),
service,
}
}
pub fn view(&self) -> Element<Message> {
let search_bar = container(scroll(
column![
row![
self.input
.view("Search")
.on_input(Message::SearchChanged)
.on_submit(Message::Search),
tip(
button("Go").on_press(Message::Search),
"Perform the search",
tip::Position::Bottom,
),
]
.spacing(10),
row![
tip(
button("Reset").on_press(Message::Reset),
"Reset the search bar",
tip::Position::Bottom,
),
tip(
pick_list(Mode::VARIANTS, Some(&self.mode), Message::ModePicked),
"Search mode",
tip::Position::Bottom,
),
tip(
pick_list(Order::VARIANTS, Some(&self.order), Message::OrderPicked),
"Field used to sort the results",
tip::Position::Bottom,
),
tip(
checkbox("Exact", self.exact).on_toggle(Message::ExactToggled),
"Exact search",
tip::Position::Bottom,
),
tip(
checkbox("Ascending", self.ascending).on_toggle(Message::AscendingToggled),
"Sort order of results",
tip::Position::Bottom,
),
tip(
pick_list(
[25, 50, 75, 100],
Some(self.limit),
Message::ShowEntriesPicked
),
"Number of results to show",
tip::Position::Bottom,
),
]
.spacing(10)
]
.padding(20)
.width(750)
.spacing(10),
))
.center_x(Fill);
column![
search_bar,
match &self.state {
State::None => Element::from(""),
State::Searching => "Searching...".into(),
// State::Aborted => "Aborted".into(),
State::Error(e) => text(e).into(),
State::Table(table) => container(lazy(table, |t| t.view())).center_x(Fill).into(),
}
]
.into()
}
pub fn update(&mut self, message: Message) -> Option<Event> {
match message {
Message::SearchChanged(s) => self.input.update(s),
Message::ModePicked(mode) => self.mode = mode,
Message::OrderPicked(order) => self.order = order,
Message::AscendingToggled(b) => self.ascending = b,
Message::ExactToggled(b) => self.exact = b,
Message::ShowEntriesPicked(x) => self.limit = x,
Message::Reset => {
let state = std::mem::take(&mut self.state);
*self = Self::new(self.service.clone());
self.state = state;
}
Message::PackagePressed(id) => return Some(Event::OpenPackage(id)),
Message::BasePressed(id) => return Some(Event::OpenBase(id)),
Message::URLPressed(url) => return Some(Event::OpenURL(url)),
Message::Search => {
let search_data = Data {
mode: self.mode.into(),
order: self.order.into(),
search: match self.input.submit() {
Ok(x) => x,
Err(t) => return Some(t.into()),
},
limit: self.limit.into(),
exact: self.exact,
ascending: self.ascending,
};
self.state = State::Searching;
let arc = self.service.clone();
return Some(
Task::perform(
async move {
let Some(service) = arc.try_lock() else {
return Err("other search request is being performed".into());
};
service.search(search_data).await
},
|r| Message::RequestResult(Arc::new(r)),
)
.into(),
);
}
Message::RequestResult(r) => match &*r {
Ok(v) => self.state = State::Table(Table(v.clone())),
Err(e) => self.state = State::Error(e.to_string()),
},
}
None
}
pub fn title(&self) -> String {
let errors = [self.input.error(), self.input.warning()];
let error = errors.into_iter().flatten().next();
match &self.state {
State::None => error.map_or_else(|| "Search".into(), Into::into),
State::Searching => "Searching...".into(),
State::Table(_) => "Displaying search results".into(),
State::Error(e) => e.into(),
}
}
}

74
src/src/widget.rs Normal file
View File

@ -0,0 +1,74 @@
use iced::widget::{Scrollable, center, container, mouse_area, scrollable, text, tooltip};
use iced::{Element, color};
/// Put content into a dark container at the center of the screen
/// which can be scrolled in multiple dirrections
pub fn centerbox<'a, Message: 'a>(
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
center(scroll(
container(content).style(container::dark).padding(20),
))
.into()
}
/// Scrollable but in both vertical and horizontal directions
pub fn scroll<'a, Message: 'a>(content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
Scrollable::with_direction(
content,
scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
},
)
.into()
}
/// Clickable url
pub fn url<'a, Message: Clone + 'a>(txt: &impl ToString, msg: Message) -> Element<'a, Message> {
Element::from(mouse_area(text(txt.to_string()).color(color!(0x00_BB_FF))).on_press(msg))
}
pub mod tip {
pub use iced::widget::tooltip::Position;
}
/// Tooltip with some styling applied
pub fn tip<'a, Message: 'a>(
content: impl Into<Element<'a, Message>>,
tip: impl ToString,
position: tip::Position,
) -> Element<'a, Message> {
tooltip(
content,
container(text(tip.to_string()).size(14))
.padding(5)
.style(container::dark),
position,
)
.into()
}
pub mod text_input {
use iced::widget::text_input::{Status, Style, default};
use iced::{Theme, color};
pub fn success(theme: &Theme, status: Status) -> Style {
Style {
background: color!(0x00_33_00).into(),
..default(theme, status)
}
}
pub fn warning(theme: &Theme, status: Status) -> Style {
Style {
background: color!(0x33_33_00).into(),
..default(theme, status)
}
}
pub fn error(theme: &Theme, status: Status) -> Style {
Style {
background: color!(0x33_00_00).into(),
..default(theme, status)
}
}
}