Compare commits

...

42 commits
flask ... main

Author SHA1 Message Date
Anthony Al Lazkani
dbfe8c2dc9 fix(): Handle invalid URL format error with proper response message 2025-01-03 00:09:24 +02:00
2b2545d348 fix(): Misc UI formatting fixes
* Fixes footer CSS
* Fixes URL example
* Increase URL timeout
2025-01-02 17:37:45 +00:00
698a631abb fix(): Updates the copyright date to 2025 2025-01-02 16:34:24 +01:00
Elia el Lazkani
1887be54e5 chore(#17): Adds Docker packaging to the project 2024-12-27 09:58:15 +01:00
Elia el Lazkani
38520c6e2a feat(#20): Adds Enable UI feature to disable serving the UI 2024-12-25 21:56:16 +01:00
Elia el Lazkani
29b233ebfb fix(): Fixes the packaging, should work as expected when installed 2024-12-25 21:34:55 +01:00
Elia el Lazkani
188ac78b1f chore(): Cleans up the docs a bit 2024-12-25 21:27:55 +01:00
Elia el Lazkani
0238e4b67b enh(#10): Frontend is now configurable from config.json
The host URL is now read from configuration.
2024-12-25 15:33:49 +01:00
Elia el Lazkani
4c36f35492 fix(#10): Removes the timestamp from the API endpoint
The timestamp is generated by the database call, the value was mapped to
TTL. It is currently removed from the API, the TTL can be added later
when needed.
2024-12-25 15:32:00 +01:00
Elia el Lazkani
8dc4e41390 chore(#10): Delegates the task of setting the url to the backend API
* API now returns a full URL instead of a stub
* Frontend now returns the value from the API as is
* Configuration adds a scheme
2024-12-25 15:29:32 +01:00
Elia el Lazkani
abbfbfbffb chore(): Removes unneeded library imports 2024-12-25 15:21:20 +01:00
Elia el Lazkani
459a96f711 fix(): Fixes unexpected default export of anonymous function warning 2024-12-25 15:20:18 +01:00
Elia el Lazkani
a925c87872 enh(#8): Refactoring code 2024-12-24 01:40:50 +01:00
Elia el Lazkani
000729a925 chore(#8): Adds scripts to install, build and run the project 2024-12-24 01:40:15 +01:00
Elia el Lazkani
c058835f73 chore(#8): Initial migration into the new frontend UI 2024-12-24 01:40:15 +01:00
Elia el Lazkani
35c8dfd21a enh(): Adds configurable CORS
* Adds CORS configuration in the config file
* Enables CORS to be toggled through config
2024-12-24 00:38:37 +00:00
Elia el Lazkani
c036d042c3 fix(): Adds the pointer to the session before proceeding 2024-12-24 01:35:37 +01:00
anthony
7f813e2c34 feature-page (#11,#4)
Refactor URL Shortening Feature and UI Enhancements
* Adds new feature page, user interface and user interface enhancement
Co-authored-by: anthony <anthony@Project42 - SCM <noreply@scm.project42.io>>
Co-committed-by: anthony <anthony@Project42 - SCM <noreply@scm.project42.io>>
2024-12-20 22:01:07 +00:00
Anthony Al Lazkani
11e9ee6a32 enh(#4): Adds new frontend 2024-12-19 01:35:29 +02:00
Elia el Lazkani
6800c90936 chore(#5): Migration to SQLAlchemy
* Introduces models to map objects in the database
* Refactoring and cleanup of old code
* Updates configuration file
* Updates README.md
* Updates .gitignore
2024-12-18 01:38:31 +01:00
Elia el Lazkani
71981a5861 chore(#5): Fixes the timestamp into native SQL 2024-12-02 00:17:27 +01:00
Elia el Lazkani
dd37cdb6a3 chore(#5): Initial SQLAlchemy building blocks 2024-12-01 22:33:55 +01:00
Elia el Lazkani
6542ad3a8c chore(): Adds Sphinx to the mix
* Adds Sphinx to the dependency list
* Generates Sphinx related configuration
* Created Sphinx related docs
2024-11-30 11:50:55 +01:00
Elia el Lazkani
d616689b9b chore(): Migrates to poetry
* Migrates the repository to use poetry
* Changes to the development dependencies
* Cleanup of old code
2024-11-30 11:08:01 +01:00
Elia el Lazkani
c7c546f371 chore(): Updates README
CouchDB changed since the last time of development, updating the
README.md to match the changes.
2024-11-30 11:07:24 +01:00
Elia el Lazkani
f10af22168 chore(): Formats the code
* Formatting the code
* Fixing imports
2024-11-30 11:07:02 +01:00
Elia el Lazkani
b2ca72a809 chore(): Updates code to run on latest python
* Updates setup.py to use the markdown file
* Replaces load() with safe_load()
2022-06-17 22:14:36 +02:00
6060bd6b07 Fixes configuration path 2021-03-20 12:03:57 +01:00
d8ccfeeb14 Converts README from rst to md 2021-03-20 12:02:42 +01:00
Elia El Lazkani
3fa367602f Fixing typing for the NoReturn type methods 2019-11-04 18:02:42 +01:00
Elia El Lazkani
e9d2b2b23e Appending TODO. 2019-11-04 17:52:37 +01:00
Elia El Lazkani
7ce20357be clean-up of code no longer needed. 2019-11-04 17:52:03 +01:00
Elia El Lazkani
28b68e9a74 More code comments and type-setting 2019-11-04 17:44:32 +01:00
Elia El Lazkani
3f42b5bb57 Adding TODO list 2019-11-04 17:44:32 +01:00
Elia El Lazkani
887aab2960 Removing the tag dependency until it is relevant. 2019-11-04 17:11:21 +01:00
Elia El Lazkani
db07547ecb Adding more information to the README 2019-11-01 19:51:45 +01:00
Elia El Lazkani
126af19e87 Adding the ability to choose upper or lower case ID generation 2019-10-13 16:42:49 +02:00
Elia El Lazkani
71a4ad4a65 Migrated the ID generation system for security reasons.
We have a new ID generator. This should make it hard to
guess other user's IDs. This is configurable.
2019-10-13 16:19:32 +02:00
Elia El Lazkani
0c44ad5c6b Moving to clipboard for copying to clipboard 2019-10-13 13:32:33 +02:00
Elia El Lazkani
6853e02b1f Migrating from aiohttp to flask due 2019-10-13 13:32:33 +02:00
Elia El Lazkani
5b37f5759c Minor fixes 2019-10-13 13:32:33 +02:00
Elia El Lazkani
54945189b6 Add LICENSE 2019-10-07 22:07:37 +00:00
65 changed files with 20368 additions and 493 deletions

37
.gitignore vendored
View file

@ -1,3 +1,40 @@
#Python
.eggs/
*.egg-info
__pycache__/
.mypy*/
# Sphinx
build/
# IDE
.vscode/
.idea/
*.db
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules/
.pnp/
.pnp.js
# testing
coverage/
# production
build/
ui/
dist/
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

25
Dockerfile Normal file
View file

@ -0,0 +1,25 @@
FROM node:23-alpine3.20 AS node
WORKDIR /shortenit
COPY frontend /shortenit
RUN npm run build
FROM python:3.13-alpine3.21 AS python
WORKDIR /shortenit
COPY . /shortenit
COPY --from=node /ui/ /shorthenit/ui/
RUN rm -rf dist/ && \
pip install poetry && \
poetry build
FROM python:3.13-alpine3.21
COPY --from=python /shortenit/dist/*.tar.gz /shortenit/
RUN pip install /shortenit/*.tar.gz && \
rm -rf /shortenit
ENTRYPOINT ["shortenit"]

25
LICENSE Normal file
View file

@ -0,0 +1,25 @@
BSD 2-Clause License
Copyright (c) 2019, Elia El Lazkani
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

21
README.md Normal file
View file

@ -0,0 +1,21 @@
# Shortenit
Shortenit is a tool to shorten urls.
**NOTE**: This is a very early draft project. Contributions are welcome.
## Install
To install `shortenit` and all of its dependencies for development, run the following script.
``` shell
$ scripts/install.sh
```
## Running
To run `shortenit` for development, edit the configuration file found in [config/config.yaml](config/config.yaml) then run the following script.
```text
$ scripts/run.sh
```

View file

@ -1,6 +0,0 @@
shortenit
=========
shortenit is a tool to shorten urls.
NOTE: This is a very early draft project. Contributions are welcome.

View file

@ -1,7 +1,22 @@
Server:
host: 127.0.0.1
hostname: localhost
bind_ip: 0.0.0.0
port: 8000
CouchDB:
username: root
password: root
url: http://localhost:5984
scheme: http
cors: False
enable_ui: True
static_folder: ui/
Database:
username: foo
password: bar
#url: "sqlite+pysqlite:///:memory:"
url: "sqlite+pysqlite:///shortenit.db"
Shortener:
# *CAUTION*: Enabling this check if the ID already exists before returning it.
# Even though this guarantees that the ID doesn't exist, this might inflict
# some performance hit.
id_length: 32
check_duplicate_id: True
id_upper_case: False

20
docs/Makefile Normal file
View file

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR =
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

5
docs/common.rst Normal file
View file

@ -0,0 +1,5 @@
Common
======
.. automodule:: shortenit.common
:members:

33
docs/conf.py Normal file
View file

@ -0,0 +1,33 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = "shortenit"
copyright = "2025, Elia el Lazkani"
author = "Elia el Lazkani"
release = "0.0.0"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.todo",
"sphinx.ext.coverage",
"sphinx.ext.viewcode",
"sphinx_autodoc_typehints",
]
templates_path = ["_templates"]
exclude_patterns = []
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = "alabaster"
html_static_path = ["_static"]

5
docs/config.rst Normal file
View file

@ -0,0 +1,5 @@
Config
======
.. automodule:: shortenit.config
:members:

31
docs/index.rst Normal file
View file

@ -0,0 +1,31 @@
.. shortenit documentation master file, created by
sphinx-quickstart on Sat Nov 30 11:26:49 2024.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Shortenit Documentation
=======================
``Shortenit`` is a tool to shorten urls.
**NOTE**: This is a very early draft project. Contributions are welcome.
.. toctree::
:maxdepth: 2
:caption: Contents:
common
config
logger
main
pointer
shortener
web
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

5
docs/logger.rst Normal file
View file

@ -0,0 +1,5 @@
Logger
======
.. automodule:: shortenit.logger
:members:

5
docs/main.rst Normal file
View file

@ -0,0 +1,5 @@
Main
====
.. automodule:: shortenit.main
:members:

35
docs/make.bat Normal file
View file

@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=docs
set BUILDDIR=build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

5
docs/pointer.rst Normal file
View file

@ -0,0 +1,5 @@
Pointer
=======
.. automodule:: shortenit.pointer
:members:

5
docs/shortener.rst Normal file
View file

@ -0,0 +1,5 @@
Shortener
=========
.. automodule:: shortenit.models.shortener
:members:

5
docs/web.rst Normal file
View file

@ -0,0 +1,5 @@
Web
===
.. automodule:: shortenit.web
:members:

46
frontend/README.md Normal file
View file

@ -0,0 +1,46 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

17501
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

53
frontend/package.json Normal file
View file

@ -0,0 +1,53 @@
{
"name": "shortenit",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.121",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"axios": "^1.7.9",
"framer-motion": "^11.15.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.4.0",
"react-router-dom": "^7.0.1",
"react-scripts": "^5.0.1",
"react-toastify": "^11.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "BUILD_PATH='../ui/' react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15"
}
}

View file

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Shorten It</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View file

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

24
frontend/src/App.css Normal file
View file

@ -0,0 +1,24 @@
@import url("https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=Outfit:wght@100..900&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=Outfit:wght@100..900&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap");
body {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: "Outfit", sans-serif;
background-color: #f5f5f5;
}
:root {
--color-primary: #f5f5f5;
--color-secondary: #19253a;
--color-tertiary: #1d4ed8;
--text-color: #333;
}
ul {
list-style: none;
}
a {
text-decoration: none;
}

16
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,16 @@
import React from "react";
import { BrowserRouter as Router } from "react-router-dom";
import "./App.css";
import AppContent from "./AppContent";
function App() {
return (
<div className="App">
<Router>
<AppContent />
</Router>
</div>
);
}
export default App;

View file

@ -0,0 +1,26 @@
import { Routes, Route, useLocation } from "react-router-dom";
import "./App.css";
import { AnimatePresence } from "framer-motion";
// Pages & Components
import Navbar from "./components/navbar/Navbar";
import Footer from "./components/footer/Footer";
import Home from "./pages/home/Home";
import Features from "./pages/features/Features";
import Contact from "./pages/contact/Contact";
export default function AppContent() {
const location = useLocation();
return (
<>
<Navbar />
<AnimatePresence mode="wait" />
<Routes location={location} key={location.pathname}>
<Route path="/" element={<Home />} />
<Route path="/features" element={<Features />} />
<Route path="/contact" element={<Contact />} />
</Routes>
<Footer />
</>
);
}

View file

@ -0,0 +1,155 @@
.URLShortener-component {
height: 300px;
width: 800px;
border-radius: 5px;
box-shadow: 0px 10px 15px rgba(0, 0, 0, 0.3), 0px 4px 6px rgba(0, 0, 0, 0.1);
display: grid;
grid-template-columns: 2fr 1fr;
transition: box-shadow 0.3s ease-in-out;
}
.URLShortener-component:hover {
box-shadow: 0px 20px 25px rgba(0, 0, 0, 0.3), 0px 8px 10px rgba(0, 0, 0, 0.2);
}
.left-side {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding-bottom: 20%;
padding-left: 5%;
padding-right: 5%;
}
.left-side h1 {
color: var(--color-tertiary);
border-bottom: 1px solid var(--color-tertiary);
}
.url-input {
display: flex;
gap: 2px;
}
.url-input input {
width: 250px;
height: 40px;
outline: none;
border-radius: 5px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border: 1px solid rgba(0, 0, 0, 0.4);
padding-left: 5px;
white-space: nowrap;
}
.url-input button {
border-radius: 5px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
background-color: var(--color-tertiary);
border: none;
min-width: 80px;
color: #fff;
transition: 0.2s;
}
.url-input button:hover {
opacity: 0.8;
}
.right-side {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
border-left: 2px solid rgba(0, 0, 0, 0.1);
padding: 0 5%;
}
.right-side-title h1 {
color: var(--color-tertiary);
}
.right-side-content p {
font-family: "Roboto", sans-serif;
font-size: 14px;
color: #808080;
}
@media (max-width: 730px) {
.URLShortener-component {
grid-template-columns: 1fr;
height: auto;
width: auto;
}
.left-side {
padding-bottom: 5%;
}
.right-side {
border-left: none;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
}
@media (max-width: 475px) {
.url-input input {
height: 25px;
width: 200px;
}
.left-side h1 {
font-size: 25px;
}
.right-side-title h1 {
font-size: 25px;
}
.right-side-content p {
font-size: 13px;
}
.url-input button {
min-width: 60px;
}
}
@media (max-width: 475px) {
.url-input {
flex-direction: column;
}
.url-input button {
height: 30px;
border-radius: 5px;
}
.url-input input {
border-radius: 5px;
}
}
.custom-toast {
font-family: "Roboto", sans-serif;
font-size: 13px;
}
@keyframes fadeIn {
to {
opacity: 1;
transform: translateY(0);
}
}
.url-input.shortened {
opacity: 0;
transform: translateY(-20px);
animation: fadeIn 0.3s forwards;
}

View file

@ -0,0 +1,160 @@
import React, { useState } from "react";
import "./URLShortener.css";
import axios from "axios";
import { ToastContainer, toast } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import Config from "../../config"
export default function URLShortener() {
const [url, setUrl] = useState<string>("");
const [shortenedUrl, setShortenedUrl] = useState<string>("");
const [showInput, setShowInput] = useState<boolean>(false);
async function ShortenIt(e: React.FormEvent) {
e.preventDefault();
if (!url) {
toast.error("Please provide a URL", {
position: "top-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "dark",
className: "custom-toast",
});
return;
}
try {
const config = Config()
const api_endpoint = "/api/v1/shorten";
const api_url = `${config.url}${api_endpoint}`;
// Send the POST request to the backend
const response = await axios.post(api_url , {url: url})
if (response.data.url) {
const shortUrl: string = response.data.url;
setShortenedUrl(shortUrl);
setShowInput(true);
toast.success("URL Shortened Successfully", {
position: "top-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "dark",
className: "custom-toast",
});
} else {
toast.error("Something went wrong while shortening the URL", {
position: "top-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "dark",
className: "custom-toast",
});
}
setTimeout(() => {
setShowInput(false);
}, 900000);
} catch (error : any) {
if (error.response) {
const errorMessage : string = error.response.data.msg || "An error occurred. Please try again.";
toast.error(errorMessage, {
position: "top-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "dark",
className: "custom-toast",
});
} else {
toast.error("Network error. Please check your connection and try again.", {
position: "top-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "dark",
className: "custom-toast",
});
}
}
}
async function copyURL() {
try {
if (shortenedUrl) {
await navigator.clipboard.writeText(shortenedUrl);
toast.success("Copied to Clipboard", {
position: "top-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "dark",
className: "custom-toast",
});
}
} catch (error) {
console.log(error);
}
}
return (
<>
<div className="URLShortener-component">
<div className="left-side">
<h1>Paste the URL</h1>
<form className="url-input" onSubmit={ShortenIt}>
<input
type="text"
placeholder="https://www.example.com"
aria-label="URL input field"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<button type="submit">Shorten It</button>
</form>
{shortenedUrl && showInput && (
<div className="url-input shortened">
<input type="text" readOnly value={shortenedUrl} />
<button onClick={copyURL}>Copy</button>
</div>
)}
</div>
<div className="right-side">
<div className="right-side-title">
<h1>Shorten It</h1>
</div>
<div className="right-side-content">
<p>
Shortenit is a free and open-source URL shortening service designed
for simplicity and efficiency.
</p>
</div>
</div>
</div>
<ToastContainer />
</>
);
}

View file

@ -0,0 +1,8 @@
import React from "react";
export interface CardProps {
icon: React.ReactNode;
title: string;
description: string;
link : string;
}

View file

@ -0,0 +1,64 @@
.Card {
position: relative;
height: 270px;
width: 250px;
border-radius: 10px;
display: grid;
grid-template-rows: repeat(2, 1fr);
overflow: hidden;
box-shadow: 0px 10px 15px rgba(0, 0, 0, 0.15), 0px 4px 6px rgba(0, 0, 0, 0.1);
font-family: "Roboto", sans-serif;
padding: 15px;
transition: .3s, transform .6s ease;
}
.Card:hover {
box-shadow: 0px 10px 15px rgba(0, 0, 0, 0.4), 0px 4px 6px rgba(0, 0, 0, 0.2);
transform: translateY(-25px);
}
.top-part {
display: flex;
justify-content: center;
align-items: center;
}
.feature-icon {
display: flex;
justify-content: center;
align-items: center;
font-size: 30px;
color: var(--color-primary);
height: 60px;
width: 60px;
border-radius: 50%;
background-color: var(--color-tertiary);
}
.bottom-part {
text-align: center;
}
.feature-description {
color: #666;
font-size: 13px;
}
.feature-button button {
padding: 8px 30px;
border-radius: 5px;
background-color: #1d4ed8;
border: 1px solid #f5f5f5;
color: #fff;
border: none;
cursor: pointer;
transition: 0.3s;
}
.feature-button button a {
color: #fff;
}
.feature-button button:hover {
opacity: 0.8;
}

View file

@ -0,0 +1,38 @@
import React from "react";
import "./FeaturesCard.css";
import { Link, useNavigate } from "react-router-dom";
import { CardProps } from "./CardProps";
const FeaturesCard: React.FC<CardProps> = ({
icon,
title,
description,
link,
}) => {
const navigate = useNavigate();
return (
<div className="Card" onClick={() => navigate(`${link}`)}>
<div className="top-part">
<div className="feature-icon">{icon}</div>
</div>
<div className="bottom-part">
<div className="feature-title">
<h3>{title}</h3>
</div>
<div className="feature-description">
<p>{description}</p>
</div>
<div className="feature-button">
<button>
<Link to={link}>Read More</Link>
</button>
</div>
</div>
</div>
);
};
export default FeaturesCard;

View file

@ -0,0 +1,8 @@
.footer {
width: 100%;
height: 100px;
background-color: var(--color-secondary);
display: flex;
justify-content: center;
align-items: center;
}

View file

@ -0,0 +1,10 @@
import React from "react";
import "./Footer.css";
function Footer() {
return (
<footer className="footer"></footer>
);
}
export default Footer;

View file

@ -0,0 +1,91 @@
.navbar {
background-color: #19253a;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
position: sticky;
top: 0;
z-index: 1000;
}
.logo a {
color: #fff;
font-size: 24px;
font-weight: bold;
}
.navbar-container {
display: flex;
}
.navbar-container ul {
display: flex;
gap: 20px;
}
.navbar-container ul li {
padding: 8px 15px;
border-radius: 5px;
transition: 0.2s;
}
.navbar-container ul li a {
color: #fff;
display: block;
}
.navbar-container ul li:hover {
background-color: #1d4ed8;
}
.hamburger {
display: none;
position: absolute;
right: 5%;
color: #fff;
font-size: 30px;
}
.navbar-btn {
padding: 8px 30px;
border-radius: 5px;
background-color: #1d4ed8;
border: 1px solid #f5f5f5;
color: #fff;
border: none;
cursor: pointer;
}
@media (max-width: 600px) {
.hamburger {
display: flex;
}
.navbar {
flex-direction: column;
align-items: flex-start;
padding: 10px 20px;
}
.logo {
justify-content: flex-start;
align-items: flex-start;
width: 100%;
}
.navbar-container {
width: 100%;
}
.navbar-container ul {
display: none;
flex-direction: column;
width: 100%;
text-align: center;
}
.navbar-container ul.open {
display: flex;
}
}

View file

@ -0,0 +1,39 @@
import React, { useState } from "react";
import "./Navbar.css";
import { Link } from "react-router-dom";
import { FaBars } from "react-icons/fa6";
function Navbar() {
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const toggleMenu = (): void => {
setMenuOpen((prevState) => !prevState);
};
return (
<header className="navbar">
<div className="logo">
<Link to="/">Shorten It</Link>
</div>
<FaBars className="hamburger" onClick={toggleMenu} />
<nav className="navbar-container">
<ul className={menuOpen ? "open" : ""}>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/features">Features</Link>
</li>
<li>
<Link to="/contact">Contact</Link>
</li>
<button className="navbar-btn">Login</button>
</ul>
</nav>
</header>
);
}
export default Navbar;

12
frontend/src/config.json Normal file
View file

@ -0,0 +1,12 @@
{
"frontend": {
"scheme": "http",
"host": "127.0.0.1",
"port": "8000"
},
"api": {
"scheme": "http",
"host": "127.0.0.1",
"port": "8000"
}
}

24
frontend/src/config.tsx Normal file
View file

@ -0,0 +1,24 @@
import configuration from './config.json';
class APIConfig {
scheme: string;
host: string;
port: string;
url: string;
constructor(scheme: string, host: string, port: string) {
this.scheme = scheme;
this.host = host;
this.port = port;
this.url = `${scheme}://${host}:${port}`
}
}
export default function Config() {
const scheme = configuration.api.scheme;
const host = configuration.api.host;
const port = configuration.api.port;
const apiConfig = new APIConfig(scheme, host, port);
return apiConfig
}

15
frontend/src/index.tsx Normal file
View file

@ -0,0 +1,15 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals

View file

View file

@ -0,0 +1,6 @@
import React from "react";
import "./Contact.css";
export default function Contact() {
return <h1>CONTACT</h1>;
}

View file

@ -0,0 +1,70 @@
.features-header {
width: 100%;
text-align: center;
font-family: "Roboto", sans-serif;
background-color: var(--color-primary);
margin-bottom: 75px;
}
.features-header h2 {
font-size: 25px;
color: var(--color-tertiary);
}
.features-header h1 {
font-size: 35px;
}
.features-body {
min-height: 100vh;
margin: 0 10%;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-row-gap: 20px;
}
@media (max-width: 1466px) {
.features-body {
display: grid;
grid-template-columns: repeat(3, 1fr);
place-items: center;
}
}
@media (max-width: 1076px) {
.features-body {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 708px) {
.features-body {
display: grid;
grid-template-columns: repeat(1, 1fr);
grid-row-gap: 20px;
}
}
@media (max-width: 390px) {
.features-body {
display: flex;
gap: 1em;
overflow-x: scroll;
scroll-snap-type: x mandatory;
}
.features-header {
margin-bottom: -50%;
}
.Feature-Card {
flex-shrink: 0;
aspect-ratio: 3/2;
scroll-snap-align: start;
}
.features-header h1 {
font-size: 25px;
}
}

View file

@ -0,0 +1,41 @@
import React from "react";
import "./Features.css";
import FeaturesCard from "../../components/featuresCard/FeaturesCard";
import { FaLink } from "react-icons/fa";
import { motion } from "framer-motion";
function Features() {
return (
<>
<motion.section
className="features-header"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.7, ease: "easeInOut" }}
>
<h2>Features</h2>
<h1>Our Services & Features</h1>
</motion.section>
<motion.section
className="features-body"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.5, ease: "easeInOut" }}
>
<div className="Feature-Card">
<FeaturesCard
icon={<FaLink />}
title="URL Shortener"
description="Transform long URLs into short, shareable links in a blink of an eye."
link="/"
/>
</div>
</motion.section>
</>
);
}
export default Features;

View file

@ -0,0 +1,8 @@
.home-page {
height: 80vh;
width: 80%;
display: flex;
align-items: center;
justify-content: center;
margin: auto;
}

View file

@ -0,0 +1,21 @@
import React from "react";
import "./Home.css";
import { motion } from "framer-motion";
import URLShortener from "../../components/URLShortener/URLShortener";
function Home() {
return (
<motion.div
className="home-page"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.5, ease: "easeInOut" }}
>
<URLShortener />
</motion.div>
);
}
export default Home;

26
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

1139
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

64
pyproject.toml Normal file
View file

@ -0,0 +1,64 @@
[tool.poetry]
name = "shortenit"
version = "0.0.0"
description = "Shortenit is a tool to shorten urls"
authors = ["Elia el Lazkani <git@lazkani.io>"]
maintainers = ["Elia el Lazkani <git@lazkani.io>", "Anthony Al Lazkani <anthonylazkani.22@gmail.com>"]
repository = "https://scm.project42.io/elia/shortenit"
license = "BSD-2-Clause"
readme = "README.md"
classifiers = [
"Development Status :: 4 - Beta",
"License :: OSI Approved :: BSD License",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS :: MacOS X",
"Programming Language :: Python",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Environment :: Console",
"Intended Audience :: Information Technology",
"Intended Audience :: System Administrators"
]
packages = [
{ include = "shortenit" },
]
include = [
{ path = "ui/", format = ["sdist", "wheel"] },
{ path = "config/", format = ["sdist", "wheel"] },
]
exclude = [
".gitignore",
"pyproject.toml",
"poetry.lock",
"docs/",
"scripts/",
"frontend/",
]
[tool.poetry.scripts]
shortenit = "shortenit.main:main"
[tool.poetry-dynamic-versioning]
enable = true
[tool.poetry.dependencies]
python = "^3.10"
PyYAML = "^6.0.2"
sqlalchemy = "^2.0.36"
pydantic = "^2.10.2"
Flask = "^3.1.0"
flask-cors = "^5.0.0"
trafaret = "^2.1.1"
[tool.poetry.group.dev.dependencies]
black = "^24.10.0"
isort = "^5.13.2"
[tool.poetry.group.docs.dependencies]
sphinx = "^8.1.3"
sphinx-autodoc-typehints = "^2.5.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View file

@ -1 +0,0 @@
setuptools-git

View file

@ -1,5 +0,0 @@
pyyaml
cloudant
aiohttp
aiohttp-jinja2
trafaret

7
scripts/install.sh Executable file
View file

@ -0,0 +1,7 @@
#!/bin/bash
cd frontend
npm install
cd ..
poetry install

6
scripts/run.sh Executable file
View file

@ -0,0 +1,6 @@
#!/bin/bash
cd frontend
npm run build
cd ..
poetry run shortenit "$@"

View file

@ -1,48 +0,0 @@
from pathlib import Path
from setuptools import setup
#<link rel=stylesheet type=text/css href="{{ url('static', filename='css/custom.css') }}">
here = Path(__file__).absolute().parent
with open(str(here.joinpath('README.rst')), encoding='utf-8') as f:
long_description = f.read()
with open(str(here.joinpath('requirements/requirements.txt')),
encoding='utf-8') as f:
install_requires = f.read()
description = 'Shortenit will shorten that url for you'
setup(
name='shortenit',
exclude_package_data={'': ['.gitignore', 'requirements/', 'setup.py']},
version_format='{tag}_{gitsha}',
setup_requires=['setuptools-git', 'setuptools-git-version'],
license='BSD',
author='Elia El Lazkani',
author_email='eliaellazkani@gmail.com',
url='https://gitlab.com/elazkani/shortenit',
python_requires='>=python3.6',
description=description,
long_description=long_description,
install_requires=install_requires,
entry_points={
'console_scripts': [
'shortenit = shortenit.main:main'
]
},
packages=['shortenit'],
classifiers=[
'Development Status :: 4 - Beta',
'License :: OSI Approved :: BSD License',
'Operating System :: POSIX :: Linux',
'Operating System :: MacOS :: MacOS X',
'Programming Language :: Python',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Environment :: Console',
'Intended Audience :: Information Technology',
'Intended Audience :: System Administrators'
],
zip_safe=False
)

View file

@ -1,6 +1,5 @@
import os
import logging
import os
# Setup logging
logger = logging.getLogger(__name__)

View file

@ -2,21 +2,45 @@ import yaml
class Config:
"""
Configuration importer.
"""
def __init__(self, config_path: str):
"""
Initialize the configuration importer.
:param config_path: The path of the configuration file.
"""
self.config_path = config_path
self.config = None
def get_config(self):
def get_config(self) -> str:
"""
Get the configuration saved in the configuration file.
:returns: The configuration saved in the configuration file.
"""
if not self.config:
_config = self.load_config()
self.config = _config
return self.config
def load_config(self):
with open(self.config_path, 'rt') as f:
config = yaml.load(f)
def load_config(self) -> str:
"""
Read the configuration saved in the configuration file.
:returns: The configuration saved in the configruation file.
"""
with open(self.config_path, "rt") as f:
config = yaml.safe_load(f)
if self.validate_config(config):
return config
def validate_config(self, config):
def validate_config(self, config: str) -> bool:
"""
Validate the configuration.
:returns: Configuration validation status.
"""
return True

View file

@ -1,29 +0,0 @@
import logging
from cloudant.document import Document
class Counter:
def __init__(self, counter_db):
self.logger = logging.getLogger(self.__class__.__name__)
self.counter_db = counter_db
self.counter = None
def get_counter(self) -> int:
with Document(self.counter_db, 'counter') as counter:
self.logger.debug("Counter: %s", counter)
try:
self.counter = counter['value']
except KeyError:
self.logger.warn(
"Counter was not initialized, initializing...")
counter['value'] = 0
try:
counter['value'] += 1
except Exception as e:
self.logger.err(e)
# Need to check if the value exists or not as to not jump values
# which it currently does but it's not a big issue for right now
self.counter = counter['value']
return self.counter

View file

@ -1,57 +0,0 @@
import time
import logging
from hashlib import sha256
from cloudant.document import Document
class Data:
def __init__(self, data_db: object,
identifier: str = None,
data: str = None):
self.logger = logging.getLogger(self.__class__.__name__)
self.data_db = data_db
self.identifier = identifier
self.data = data
self.timestamp = time.time()
self.pointers = []
self.data_found = None
self.populate()
def generate_identifier(self):
hash_object = sha256(self.data.encode('utf-8'))
self.identifier = hash_object.hexdigest()
def populate(self, pointer: str = None):
if self.identifier:
self.logger.debug("The identifier is set, retrieving data...")
self.get_data()
elif self.data:
self.logger.debug("The data is set, generating an identifier...")
self.generate_identifier()
self.logger.debug("Attempting to get the data with "
"the identifier generated...")
self.get_data()
if not self.data_found:
self.logger.debug("The data generated is not found, "
"creating...")
self.set_data(pointer)
def get_data(self):
with Document(self.data_db, self.identifier) as data:
try:
self.data = data['value']
self.timestamp = data['timestamp']
self.pointers = data['pointers']
self.data_found = True
except KeyError:
self.data_found = False
def set_data(self, pointer):
with Document(self.data_db, self.identifier) as data:
data['value'] = self.data
data['timestamp'] = self.timestamp
try:
data['pointers'].append(pointer)
except KeyError:
data['pointers'] = [pointer]

View file

@ -1,57 +0,0 @@
import logging
from cloudant.client import CouchDB
class DB:
def __init__(self, config: dict) -> None:
self.logger = logging.getLogger(self.__class__.__name__)
self.username = config['username']
self.password = config['password']
self.url = config['url']
self.client = None
self.session = None
def initialize_shortenit(self):
try:
self.counter_db = self.client['counter']
except KeyError:
self.logger.warn(
"The 'counter' database was not found, creating...")
self.counter_db = self.client.create_database('counter')
if self.counter_db.exists():
self.logger.info(
"The 'counter' database was successfully created.")
try:
self.data_db = self.client['data']
except KeyError:
self.logger.warn(
"The 'data' database was not found, creating...")
self.data_db = self.client.create_database('data')
if self.data_db.exists():
self.logger.info(
"The 'data' database was successfully created.")
try:
self.pointers_db = self.client['pointers']
except KeyError:
self.logger.warn(
"The 'pointers' database was not found, creating...")
self.pointers_db = self.client.create_database('pointers')
if self.pointers_db.exists():
self.logger.info(
"The 'pointers' database was successfully created.")
def __enter__(self) -> CouchDB:
"""
"""
self.client = CouchDB(self.username, self.password,
url=self.url, connect=True)
self.session = self.client.session()
return self
def __exit__(self, *args) -> None:
"""
"""
self.client.disconnect()

View file

@ -1,12 +1,17 @@
import os
import yaml
import logging.config
import os
import typing
import yaml
from .common import check_file
def setup_logging(default_path: str = None,
default_level: int = logging.ERROR,
env_key: str = 'LOG_CFG') -> None:
def setup_logging(
default_path: str = None,
default_level: int = logging.ERROR,
env_key: str = "LOG_CFG",
) -> typing.NoReturn:
"""
Method that sets the logging system up.
@ -29,10 +34,12 @@ def setup_logging(default_path: str = None,
path = None
if path:
with open(path, mode='r') as f:
with open(path, mode="r") as f:
config = yaml.safe_load(f.read())
logging.config.dictConfig(config)
else:
_format = '%(asctime)s - %(levelname)s - %(filename)s:' \
'%(name)s.%(funcName)s:%(lineno)d - %(message)s'
_format = (
"%(asctime)s - %(levelname)s - %(filename)s:"
"%(name)s.%(funcName)s:%(lineno)d - %(message)s"
)
logging.basicConfig(level=default_level, format=_format)

View file

@ -2,68 +2,85 @@
import argparse
import logging
import pathlib
import asyncio
import sys
import typing
import time
from sqlalchemy import create_engine, exc
from sqlalchemy.orm import Session
from .data import Data
from .pointer import Pointer
from .config import Config
from .counter import Counter
from .db import DB
from .logger import setup_logging
from .web import Web, SiteHandler
from shortenit.config import Config
from shortenit.logger import setup_logging
from shortenit.models.base import Base
from shortenit.models.objects import Link, Pointer
from shortenit.models.shortener import Shortener
from shortenit.web import SiteHandler, Web
PROJECT_ROOT = pathlib.Path(__file__).parent.parent
CONFIGURATION = f'{PROJECT_ROOT}/config/config.yaml'
CONFIGURATION = f"{PROJECT_ROOT}/config/config.yaml"
# Setup logging
logger = logging.getLogger(__name__)
def main() -> None:
def main() -> typing.NoReturn:
"""
Main method
"""
parser = argument_parse()
args = parser.parse_args()
debug = True if args.verbose > 0 else False
verbosity_level = verbosity(args.verbose)
setup_logging(args.logger, verbosity_level)
config = Config(CONFIGURATION).get_config()
db_config = config.get('CouchDB', None)
server_config = config.get('Server', None)
if args.config:
config = Config(args.config).get_config()
else:
config = Config(CONFIGURATION).get_config()
db_config = config.get("Database", None)
server_config = config.get("Server", None)
if db_config:
with DB(db_config) as db:
db.initialize_shortenit()
try:
engine = create_engine(db_config["url"], echo=False, future=True)
Base.metadata.create_all(bind=engine)
loop = asyncio.get_event_loop()
handler = SiteHandler(db, shorten_url, lenghten_url)
web = Web(loop, handler)
web.host = server_config.get('host', None)
web.port = server_config.get('port', None)
web.start_up()
with Session(bind=engine, autoflush=True, future=True) as session:
handler = SiteHandler(config, session, shorten_it, lengthen_it)
web = Web(handler, debug=debug)
web.host = server_config.get("bind_ip", None)
web.port = server_config.get("port", None)
web.start_up()
except:
sys.exit(1)
sys.exit(0)
def shorten_url(database: DB, data: str, ttl: time.time):
counter = Counter(database.counter_db)
data = Data(database.data_db,
data=data)
data.populate()
pointer = Pointer(database.pointers_db, counter)
pointer.generate_pointer(
data.identifier,
ttl
)
data.set_data(pointer.identifier)
return pointer.identifier
def shorten_it(config: dict, session: Session, data: str, ttl: int = 0):
shortener = Shortener(session, config)
identifier = shortener.generate_uuid()
if identifier:
try:
_link = session.query(Link).filter_by(data=data).one()
except exc.NoResultFound:
logger.debug("Link '%s' was not found in the database.", data)
_link = Link(data=data, pointers=[])
_pointer = Pointer(data=identifier, link_id=_link.id, link=_link, ttl=ttl)
session.add(_pointer)
_link.pointers.append(_pointer)
session.add(_link)
session.commit()
return _pointer.data
return None
def lenghten_url(database: DB, identifier: str):
pointer = Pointer(database.pointers_db)
pointer.get_pointer(identifier)
data = Data(database.data_db, identifier=pointer.data_hash)
data.populate()
return data.data
def lengthen_it(session: Session, identifier: str):
try:
_pointer = session.query(Pointer).filter_by(data=identifier).one()
except exc.NoResultFound:
logger.debug("Pointer '%s' was not found in the database.", identifier)
return None
return _pointer.link.data
def argument_parse() -> argparse.ArgumentParser:
@ -73,15 +90,18 @@ def argument_parse() -> argparse.ArgumentParser:
:returns: The argument parser.
"""
parser = argparse.ArgumentParser(
description="Generates rundeck resources "
"file from different API sources.")
description="Generates rundeck resources " "file from different API sources."
)
parser.add_argument(
'-v', '--verbose', action='count', default=0,
help='Verbosity level to use.')
"-v", "--verbose", action="count", default=0, help="Verbosity level to use."
)
parser.add_argument(
'-l', '--logger', type=str,
help='The logger YAML configuration file.')
"-l", "--logger", type=str, help="The logger YAML configuration file."
)
parser.add_argument(
"-c", "--config", type=str, help="The shortenit configuration file."
)
return parser
@ -100,3 +120,7 @@ def verbosity(verbose: int):
return logging.INFO
elif verbose > 2:
return logging.DEBUG
if __name__ == "__main__":
main()

View file

5
shortenit/models/base.py Normal file
View file

@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
class Base(MappedAsDataclass, DeclarativeBase):
pass

View file

@ -0,0 +1,27 @@
from datetime import datetime
from typing import List
from pydantic import AnyHttpUrl
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
import shortenit.models.base as base
class Link(base.Base):
__tablename__ = "links"
id: Mapped[int] = mapped_column(primary_key=True, index=True, init=False)
data: Mapped[AnyHttpUrl] = mapped_column(String, index=True)
pointers: Mapped[List["Pointer"]] = relationship(back_populates="link")
timestamp: Mapped[datetime] = mapped_column(default=func.now())
class Pointer(base.Base):
__tablename__ = "pointers"
id: Mapped[int] = mapped_column(primary_key=True, index=True, init=False)
data: Mapped[str] = mapped_column(index=True)
ttl: Mapped[int]
link_id: Mapped[int] = mapped_column(ForeignKey("links.id"))
link: Mapped["Link"] = relationship(back_populates="pointers")
timestamp: Mapped[datetime] = mapped_column(default=func.now())

View file

@ -0,0 +1,92 @@
import logging
import typing
import uuid
from sqlalchemy.orm import exc
import shortenit.models.objects as objects
class Shortener:
"""
Shortener Object
"""
def __init__(self, session, configuration: dict) -> typing.NoReturn:
"""
Initialize the Shortener object.
:param configuration: The shortenit configuration
"""
self.logger = logging.getLogger(self.__class__.__name__)
self.session = session
self.configuration = configuration
self.length = 32
self.check_duplicate = False
self.upper_case = False
self.init()
def init(self) -> typing.NoReturn:
"""
Initialize the shortener from the configuration.
"""
length = self.configuration.get("id_length", 32)
if length > 32 or length <= 8:
self.length = 32
self.logger.warn(
"ID length provided is not between '8' and '32', reverting to default of '32'"
)
else:
self.length = length
self.check_duplicate = self.configuration.get("check_duplicate_id", False)
self.upper_case = self.configuration.get("id_upper_case", False)
def generate_short_uuid(self) -> str:
"""
Method to generate UUID.
:returns: A UUID.
"""
_uuid = uuid.uuid1().hex
if self.upper_case:
return _uuid.upper()[-self.length :]
return _uuid.lower()[-self.length :]
def check_uuid(self, short_uuid: str) -> bool:
"""
Method to check the short UUID against the database.
:returns: True if UUID exists in the database.
"""
try:
_ = self.session.query(objects.Pointer).filter_by(data=short_uuid).one()
except exc.NoResultFound:
self.logger.debug(
"Generated short uuid '%s' was not found in the database.", short_uuid
)
return False
self.logger.warn("Generated short uuid '%s' was found in the database.")
return True
def generate_uuid(self) -> str:
"""
Method to generate a UUID.
This method will generate a UUID and check if it already exists.
:returns: A UUID.
"""
short_uuid = self.generate_short_uuid()
if self.check_duplicate:
counter = 0
while self.check_uuid(short_uuid=short_uuid):
if counter > 10:
self.logger.err(
"Cannot generate a new unique ID,"
"try to configure a longer ID length."
)
return None
short_uuid = self.generate_short_uuid()
counter += 1
self.logger.debug("Returning ID: '%s'", short_uuid)
return short_uuid

View file

@ -1,67 +0,0 @@
import time
import logging
from .counter import Counter
from cloudant.document import Document
CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
class Pointer:
def __init__(self, pointers_db: object,
counter: Counter = None) -> None:
self.logger = logging.getLogger(self.__class__.__name__)
self.pointers_db = pointers_db
self.counter = counter
self.identifier = None
self.data_hash = None
self.ttl = None
self.timestamp = time.time()
def generate_pointer(self, data_hash: str, ttl: time.time):
self.logger.debug("Generating new counter...")
counter = self.counter.get_counter()
self.logger.debug("Encoding the counter into an ID")
self.identifier = Pointer.encode(counter)
self.logger.debug("Encoded counter is %s", self.identifier)
with Document(self.pointers_db, self.identifier) as pointer:
pointer['value'] = data_hash
pointer['ttl'] = ttl
pointer['timestamp'] = self.timestamp
self.data_hash = data_hash
self.ttl = ttl
return self
def get_pointer(self, identifier: str):
with Document(self.pointers_db, identifier) as pointer:
try:
self.identifier = pointer['_id']
self.data_hash = pointer['value']
self.ttl = pointer['ttl']
self.timestamp = pointer['timestamp']
return self
except KeyError:
pass
return None
@staticmethod
def encode(counter):
sign = '-' if counter < 0 else ''
counter = abs(counter)
result = ''
while counter > 0:
counter, remainder = divmod(counter, len(CHARS))
result = CHARS[remainder]+result
return sign+result
@staticmethod
def decode(counter):
return int(counter, len(CHARS))
@staticmethod
def padding(counter: str, count: int=6):
if len(counter) < count:
pad = '0' * (count - len(counter))
return f"{pad}{counter}"
return f"{counter}"

View file

@ -1,78 +1,132 @@
import os
import logging
import aiohttp_jinja2
import jinja2
import trafaret as t
from aiohttp import web
from pathlib import Path
from urllib.parse import urlunparse
import trafaret
from flask import Flask, abort, redirect, request, send_from_directory
from flask_cors import CORS
from .common import check_file
class Web:
def __init__(self, loop, handler):
def __init__(self, handler, debug=False):
self.logger = logging.getLogger(self.__class__.__name__)
self.loop = loop
self.app = None
self.host = None
self.port = None
self.handler = handler
self.router = None
self.loader = None
self.debug = debug
def start_up(self):
self.loop.run_until_complete(self.init())
web.run_app(self.app, host=self.host, port=self.port)
self.init()
self.app.run(host=self.host, port=self.port, debug=self.debug)
async def init(self):
self.app = web.Application(loop=self.loop)
templates = Path(__file__).absolute().parent.parent.joinpath('templates')
self.loader = jinja2.FileSystemLoader(str(templates))
self.logger.debug(str(templates))
aiohttp_jinja2.setup(self.app,
loader=self.loader)
def init(self):
server_config = self.handler.configuration.get("Server", None)
self.app = Flask(__name__)
self.setup_routes()
if server_config and server_config.get("cors", False):
self.logger.debug("Enabling CORS...")
CORS(self.app)
def setup_routes(self):
self.router = self.app.router
self.router.add_get('/', self.handler.index,
name='index')
self.router.add_post('/shortenit', self.handler.shortenit,
name='shortenit')
self.router.add_get('/{identifier}', self.handler.redirect,
name='redirect')
if self.handler.configuration.get("Server", None)["enable_ui"]:
self.app.add_url_rule(
"/", "/", self.handler.index, methods=["GET"], defaults={"path": ""}
)
self.app.add_url_rule("/<path:path>", "/", self.handler.index, methods=["GET"])
self.app.add_url_rule(
"/static/css/<path:path>", "css", self.handler.css, methods=["GET"]
)
self.app.add_url_rule(
"/static/js/<path:path>", "js", self.handler.js, methods=["GET"]
)
self.app.add_url_rule(
"/api/v1/shorten", "shorten", self.handler.shortenit, methods=["POST"]
)
self.app.add_url_rule(
"/r/<identifier>", "redirect", self.handler.short_redirect, methods=["GET"]
)
class SiteHandler:
def __init__(self, database, shorten_url, lenghten_url):
def __init__(self, configuration, database, shorten_url, lenghten_url):
self.logger = logging.getLogger(self.__class__.__name__)
self.configuration = configuration
self.database = database
self.shorten_url = shorten_url
self.lenghten_url = lenghten_url
self.shortenit_load_format = t.Dict({
t.Key('url'): t.URL,
t.Key('timestamp'): t.Int
})
self.shortenit_load_format = trafaret.Dict({trafaret.Key("url"): trafaret.URL})
async def shortenit(self, request):
data = await request.json()
self.logger.debug(data)
def _get_server_config(self):
return self.configuration.get("Server", None)
def _get_host(self):
host = self._get_server_config()["hostname"]
port = self._get_server_config()["port"]
scheme = self._get_server_config()["scheme"]
return scheme, host, port
def _get_url(self, stub):
scheme, host, port = self._get_host()
if (port == "443" and scheme == "https") or (port == "80" and scheme == "http"):
return urlunparse((scheme, f"{host}", f"/r/{stub}", "", "", ""))
return urlunparse((scheme, f"{host}:{port}", f"/r/{stub}", "", "", ""))
def shortenit(self):
data = request.get_json()
try:
data = self.shortenit_load_format(data)
except t.DataError:
raise web.HTTPBadRequest('URL is not valid')
short_url = self.shorten_url(self.database, data['url'], data['timestamp'])
except Exception as e:
self.logger.error(e)
return {"msg" : "Invalid URL format"}, 400
self.logger.error(e)
abort(400)
try:
stub = self.shorten_url(
self.configuration.get("Shortener", None),
self.database,
data["url"],
)
short_url = self._get_url(stub)
except KeyError as e:
self.logger.error(e)
abort(400)
self.logger.debug(short_url)
return web.json_response({"url": short_url})
return {"url": short_url}
async def redirect(self, request):
identifier = request.match_info['identifier']
def short_redirect(self, identifier):
url = self.lenghten_url(self.database, identifier)
self.logger.debug("The URL is...")
self.logger.debug(url)
if not url:
raise web.HTTPNotFound()
return web.HTTPFound(location=url)
abort(404)
return redirect(url)
@aiohttp_jinja2.template('index.html')
async def index(self, request):
return {}
def index(self, path):
if path != "":
return self._fetch_from_directory(path)
else:
return self._fetch_from_directory("index.html")
def css(self, path):
path = "static/css/" + path
return self._fetch_from_directory(path)
def js(self, path):
path = "static/js/" + path
return self._fetch_from_directory(path)
def _fetch_from_directory(self, path):
try:
project_root = Path(__file__).parent.parent
static_folder = (
f"{project_root}/" + self._get_server_config()["static_folder"]
)
if check_file(static_folder + "/" + path):
return send_from_directory(static_folder, path)
else:
abort(404)
except:
abort(500)

View file

@ -1,110 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="The personal URL Shortener">
<meta name="author" content="">
<title>ShortenIt</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<script src="https://code.jquery.com/jquery-3.4.1.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
</head>
<script type="text/javascript">
var href = window.location.href;
$(function() {
$('#submitButton').click(function() {
$.ajax({
type: "POST",
url: "/shortenit",
data: JSON.stringify({'url' : $('#url-input').val(),
'timestamp': Date.now()}),
success: returnSuccess,
dataType: 'json',
contentType: "application/json",
});
});
});
function returnSuccess(data, textStatus, jqXHR) {
var url = href.concat(data.url);
console.log(window.location.href)
if(data.url) {
document.getElementById("url-result").value = url;
} else {
document.getElementById("url-result").value = "Please enter a valid URL!";
}
}
function copyToClipboard() {
/* Get the text field */
var copyText = document.querySelector("#url-result");
/* Select the text field */
copyText.select();
copyText.setSelectionRange(0, 99999); /*For mobile devices*/
/* Copy the text inside the text field */
document.execCommand("copy");
/* Alert the copied text */
alert("Copied the text: " + copyText.value);
}
</script>
<body>
<!-- Page Content -->
<div class="container">
<div class="row">
<div class="col-lg-12 text-center">
<h1 class="mt-5">Shorten It!</h1>
</div>
</div>
<div class="row">
<div class="col-lg-12 form-group">
<div class="form-row">
<div class="col-9">
<input type="text" class="form-control" name="url" id="url-input" placeholder="https://www.duckduckgo.com" />
</div>
<div class="col-3">
<button id="submitButton" type="button" class="btn btn-outline-primary">Shorten URL</button>
</div>
</div>
</div>
</div>
<!-- <a href="#" id="url-result">Enter URL</a> -->
<div class="row">
<div class="col-lg-12 form-group">
<div class="form-row">
<div class="col-9">
<input type="text" id="url-result" class="form-control" readonly>
</div>
<div class="col-3">
<button class="btn btn-outline-primary" type="button" id="copy-button" data-toggle="tooltip" onclick="copyToClipboard()" data-placement="button" title="Copy to Clipboard">Copy</button>
</div>
</div>
</div>
</div>
<!-- Bootstrap core JavaScript -->
</body>
</html>