├── website ├── lib │ ├── views │ │ ├── package │ │ │ ├── widgets │ │ │ │ ├── dependencies.dart │ │ │ │ ├── content_entry.dart │ │ │ │ ├── tabs │ │ │ │ │ ├── readme.dart │ │ │ │ │ ├── versions.dart │ │ │ │ │ ├── installing.dart │ │ │ │ │ └── dependencies.dart │ │ │ │ ├── tabs.dart │ │ │ │ └── header.dart │ │ │ └── package.dart │ │ ├── packages │ │ │ ├── widgets │ │ │ │ ├── result_overview.dart │ │ │ │ ├── page_selector.dart │ │ │ │ ├── package_tile.dart │ │ │ │ ├── search_bar.dart │ │ │ │ └── action_bar.dart │ │ │ └── packages.dart │ │ ├── admin │ │ │ ├── widgets │ │ │ │ ├── revoke_key_dialog.dart │ │ │ │ ├── action_bar.dart │ │ │ │ ├── create_key_dialog.dart │ │ │ │ └── access_key_tile.dart │ │ │ └── admin.dart │ │ └── authentication │ │ │ ├── widgets │ │ │ └── password_field.dart │ │ │ └── authentication.dart │ ├── main.dart │ ├── theme │ │ ├── data │ │ │ ├── sizes.dart │ │ │ ├── radius.dart │ │ │ ├── spacing.dart │ │ │ ├── data.dart │ │ │ ├── typography.dart │ │ │ └── colors.dart │ │ └── theme.dart │ ├── widgets │ │ ├── footer.dart │ │ ├── text_button.dart │ │ ├── flat_button.dart │ │ └── sliver_markdown.dart │ ├── app.dart │ ├── routing.dart │ └── state │ │ ├── state.dart │ │ └── notifier.dart ├── README.md ├── web │ ├── favicon.png │ ├── icons │ │ ├── Icon-192.png │ │ ├── Icon-512.png │ │ ├── Icon-maskable-192.png │ │ └── Icon-maskable-512.png │ ├── manifest.json │ └── index.html ├── fonts │ ├── Roboto-Black.ttf │ ├── Roboto-Bold.ttf │ ├── Roboto-Italic.ttf │ ├── Roboto-Light.ttf │ ├── Roboto-Medium.ttf │ ├── Roboto-Thin.ttf │ ├── Roboto-Regular.ttf │ ├── Roboto-BlackItalic.ttf │ ├── Roboto-BoldItalic.ttf │ ├── Roboto-LightItalic.ttf │ ├── Roboto-ThinItalic.ttf │ └── Roboto-MediumItalic.ttf ├── .metadata ├── scripts │ └── bundler.dart ├── .vscode │ └── launch.json ├── .gitignore ├── analysis_options.yaml └── pubspec.yaml ├── micropub ├── CHANGELOG.md ├── example │ ├── execute.sh │ ├── publish.sh │ └── config.json ├── lib │ ├── client.dart │ ├── server.dart │ └── src │ │ ├── server │ │ ├── utils │ │ │ ├── middleware_log.dart │ │ │ └── yaml.dart │ │ ├── controllers │ │ │ ├── static.dart │ │ │ └── api │ │ │ │ ├── api.g.dart │ │ │ │ ├── download.g.dart │ │ │ │ ├── admin.g.dart │ │ │ │ ├── packages.g.dart │ │ │ │ ├── download.dart │ │ │ │ ├── admin.dart │ │ │ │ ├── api.dart │ │ │ │ └── packages.dart │ │ ├── storage │ │ │ ├── storage.dart │ │ │ └── hive.dart │ │ ├── options.g.dart │ │ ├── auth │ │ │ ├── hive.dart │ │ │ └── auth.dart │ │ ├── options.dart │ │ └── server.dart │ │ ├── shared │ │ ├── model.dart │ │ └── model.g.dart │ │ └── client │ │ └── client.dart ├── .gitignore ├── pubspec.yaml ├── LICENSE ├── analysis_options.yaml ├── README.md └── bin │ └── micropub.dart ├── .DS_Store ├── screenshot.png ├── README.md ├── micropub.code-workspace ├── install.sh └── .github └── workflows └── release.yml /website/lib/views/package/widgets/dependencies.dart: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /micropub/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.0.0 2 | 3 | - Initial version. 4 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | # website 2 | 3 | The micropub website. 4 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/.DS_Store -------------------------------------------------------------------------------- /micropub/example/execute.sh: -------------------------------------------------------------------------------- 1 | dart ../bin/micropub.dart server -c config.json -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/screenshot.png -------------------------------------------------------------------------------- /micropub/lib/client.dart: -------------------------------------------------------------------------------- 1 | export 'src/client/client.dart'; 2 | export 'src/shared/model.dart'; 3 | -------------------------------------------------------------------------------- /website/web/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/website/web/favicon.png -------------------------------------------------------------------------------- /website/fonts/Roboto-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/website/fonts/Roboto-Black.ttf -------------------------------------------------------------------------------- /website/fonts/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/website/fonts/Roboto-Bold.ttf -------------------------------------------------------------------------------- /website/fonts/Roboto-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/website/fonts/Roboto-Italic.ttf -------------------------------------------------------------------------------- /website/fonts/Roboto-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/website/fonts/Roboto-Light.ttf -------------------------------------------------------------------------------- /website/fonts/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/website/fonts/Roboto-Medium.ttf -------------------------------------------------------------------------------- /website/fonts/Roboto-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/website/fonts/Roboto-Thin.ttf -------------------------------------------------------------------------------- /website/web/icons/Icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/website/web/icons/Icon-192.png -------------------------------------------------------------------------------- /website/web/icons/Icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/website/web/icons/Icon-512.png -------------------------------------------------------------------------------- /website/fonts/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/website/fonts/Roboto-Regular.ttf -------------------------------------------------------------------------------- /website/fonts/Roboto-BlackItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/website/fonts/Roboto-BlackItalic.ttf -------------------------------------------------------------------------------- /website/fonts/Roboto-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/website/fonts/Roboto-BoldItalic.ttf -------------------------------------------------------------------------------- /website/fonts/Roboto-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/website/fonts/Roboto-LightItalic.ttf -------------------------------------------------------------------------------- /website/fonts/Roboto-ThinItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/website/fonts/Roboto-ThinItalic.ttf -------------------------------------------------------------------------------- /website/fonts/Roboto-MediumItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/website/fonts/Roboto-MediumItalic.ttf -------------------------------------------------------------------------------- /micropub/lib/server.dart: -------------------------------------------------------------------------------- 1 | export 'src/server/server.dart'; 2 | export 'src/server/options.dart'; 3 | export 'src/shared/model.dart'; 4 | -------------------------------------------------------------------------------- /website/web/icons/Icon-maskable-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/website/web/icons/Icon-maskable-192.png -------------------------------------------------------------------------------- /website/web/icons/Icon-maskable-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aloisdeniel/micropub/HEAD/website/web/icons/Icon-maskable-512.png -------------------------------------------------------------------------------- /website/lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'app.dart'; 4 | 5 | void main() { 6 | runApp(const MicropubWebsite()); 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # micropub 2 | 3 | ![screenshot.png](screenshot.png) 4 | 5 | A minimalist private pub server for small teams. 6 | 7 | [Setup and usage](/micropub/README.md) -------------------------------------------------------------------------------- /micropub.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": ".github" 5 | }, 6 | { 7 | "path": "micropub" 8 | }, 9 | { 10 | "path": "website" 11 | } 12 | ], 13 | "settings": {} 14 | } -------------------------------------------------------------------------------- /micropub/example/publish.sh: -------------------------------------------------------------------------------- 1 | export MICROPUB_ACCESS_KEY=f54a3016-7929-4ea6-9643-29a5b580508a 2 | #dart ../bin/micropub.dart publish test-packages/foo 3 | #dart ../bin/micropub.dart publish test-packages/bar 4 | dart ../bin/micropub.dart publish test-packages/baz -------------------------------------------------------------------------------- /micropub/lib/src/server/utils/middleware_log.dart: -------------------------------------------------------------------------------- 1 | import 'package:logging/logging.dart'; 2 | import 'package:shelf/shelf.dart'; 3 | 4 | Middleware logMiddleware(String name) => (innerHandler) { 5 | return ((request) { 6 | Logger.root.info('[$name]'); 7 | return innerHandler(request); 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /micropub/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated files 2 | static/ 3 | example/test-packages 4 | 5 | # Files and directories created by pub. 6 | .dart_tool/ 7 | .packages 8 | 9 | # Conventional directory for build output. 10 | build/ 11 | 12 | example/*.hive 13 | example/*.lock 14 | example/packages/ 15 | bin/certificates/ 16 | 17 | static.g.dart 18 | 19 | .DS_Store -------------------------------------------------------------------------------- /website/.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: db747aa1331bd95bc9b3874c842261ca2d302cd5 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /micropub/example/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "directory": ".", 3 | "adminEmail": "admin@company.com", 4 | "adminKey": "adm1n", 5 | "name": "Company", 6 | "distantHostUrl": "https://micropub.company.com/", 7 | "host": "localhost", 8 | "port": 8081, 9 | "ignored": { 10 | "sslCert": "ssl.cert", 11 | "sslKey": "ssl.key" 12 | } 13 | } -------------------------------------------------------------------------------- /website/lib/theme/data/sizes.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | class SizesData extends Equatable { 4 | const SizesData({ 5 | required this.maxWidth, 6 | }); 7 | 8 | const SizesData.regular() : maxWidth = 800; 9 | 10 | final double maxWidth; 11 | 12 | @override 13 | List get props => [ 14 | maxWidth, 15 | ]; 16 | } 17 | -------------------------------------------------------------------------------- /website/lib/theme/data/radius.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | class RadiusData extends Equatable { 5 | const RadiusData({ 6 | required this.regular, 7 | }); 8 | 9 | const RadiusData.regular() : regular = const Radius.circular(4); 10 | 11 | final Radius regular; 12 | 13 | @override 14 | List get props => [ 15 | regular, 16 | ]; 17 | } 18 | -------------------------------------------------------------------------------- /micropub/lib/src/server/controllers/static.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:path/path.dart'; 4 | import 'package:shelf/shelf.dart'; 5 | import 'package:shelf_static/shelf_static.dart'; 6 | 7 | class StaticController { 8 | const StaticController(); 9 | 10 | Handler get handler { 11 | final directory = join( 12 | Directory.current.path, 13 | 'static', 14 | ); 15 | return createStaticHandler( 16 | directory, 17 | defaultDocument: 'index.html', 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /micropub/lib/src/server/controllers/api/api.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'api.dart'; 4 | 5 | // ************************************************************************** 6 | // ShelfRouterGenerator 7 | // ************************************************************************** 8 | 9 | Router _$ApiControllerRouter(ApiController service) { 10 | final router = Router(); 11 | router.add('GET', r'/me', service.me); 12 | router.add('GET', r'/info', service.info); 13 | return router; 14 | } 15 | -------------------------------------------------------------------------------- /micropub/lib/src/server/controllers/api/download.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'download.dart'; 4 | 5 | // ************************************************************************** 6 | // ShelfRouterGenerator 7 | // ************************************************************************** 8 | 9 | Router _$DownloadControllerRouter(DownloadController service) { 10 | final router = Router(); 11 | router.add('GET', r'//versions/.tar.gz', service.download); 12 | return router; 13 | } 14 | -------------------------------------------------------------------------------- /micropub/lib/src/server/utils/yaml.dart: -------------------------------------------------------------------------------- 1 | import 'package:yaml/yaml.dart'; 2 | 3 | convertYaml(dynamic value) { 4 | if (value is YamlMap) { 5 | return value 6 | .cast() 7 | .map((k, v) => MapEntry(k, convertYaml(v))); 8 | } 9 | if (value is YamlList) { 10 | return value.map((e) => convertYaml(e)).toList(); 11 | } 12 | return value; 13 | } 14 | 15 | Map? loadYamlAsMap(dynamic value) { 16 | var yamlMap = loadYaml(value) as YamlMap?; 17 | return convertYaml(yamlMap).cast(); 18 | } 19 | -------------------------------------------------------------------------------- /micropub/lib/src/server/controllers/api/admin.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'admin.dart'; 4 | 5 | // ************************************************************************** 6 | // ShelfRouterGenerator 7 | // ************************************************************************** 8 | 9 | Router _$AdminApiControllerRouter(AdminApiController service) { 10 | final router = Router(); 11 | router.add('GET', r'/users', service.getAccessKeys); 12 | router.add('POST', r'/users', service.createAccessKey); 13 | router.add('DELETE', r'/users/', service.revokeAccessKey); 14 | return router; 15 | } 16 | -------------------------------------------------------------------------------- /website/lib/theme/data/spacing.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | class SpacingData extends Equatable { 4 | const SpacingData({ 5 | required this.small, 6 | required this.regular, 7 | required this.big, 8 | required this.extraBig, 9 | }); 10 | 11 | const SpacingData.regular() 12 | : small = 10, 13 | regular = 20, 14 | big = 30, 15 | extraBig = 64; 16 | 17 | final double small; 18 | final double regular; 19 | final double big; 20 | final double extraBig; 21 | 22 | @override 23 | List get props => [ 24 | small, 25 | regular, 26 | big, 27 | extraBig, 28 | ]; 29 | } 30 | -------------------------------------------------------------------------------- /micropub/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: micropub 2 | description: A simple command-line application. 3 | version: 1.0.0 4 | # homepage: https://www.example.com 5 | 6 | environment: 7 | sdk: '>=2.16.1 <3.0.0' 8 | 9 | dependencies: 10 | archive: ^3.2.2 11 | args: ^2.3.0 12 | freezed_annotation: ^1.1.0 13 | hive: ^2.0.6 14 | http: ^0.13.4 15 | json_annotation: ^4.4.0 16 | logging: ^1.0.2 17 | path: ^1.8.0 18 | shelf: ^1.2.0 19 | shelf_cors_headers: ^0.1.2 20 | shelf_router: ^1.1.2 21 | shelf_static: ^1.1.0 22 | uuid: ^3.0.6 23 | yaml: ^3.1.0 24 | 25 | dev_dependencies: 26 | build_runner: ^2.1.7 27 | freezed: ^1.1.1 28 | json_serializable: ^6.1.5 29 | lints: ^1.0.0 30 | shelf_router_generator: ^1.0.2 31 | -------------------------------------------------------------------------------- /website/lib/theme/theme.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | 3 | import 'data/data.dart'; 4 | 5 | class AppTheme extends InheritedWidget { 6 | const AppTheme({ 7 | Key? key, 8 | this.data, 9 | required Widget child, 10 | }) : super( 11 | key: key, 12 | child: child, 13 | ); 14 | 15 | final AppThemeData? data; 16 | 17 | static final AppThemeData fallback = AppThemeData.fallback(); 18 | 19 | static AppThemeData of(BuildContext context) { 20 | return context.dependOnInheritedWidgetOfExactType()?.data ?? 21 | fallback; 22 | } 23 | 24 | @override 25 | bool updateShouldNotify(covariant AppTheme oldWidget) { 26 | return data != oldWidget.data; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /website/scripts/bundler.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print 2 | 3 | import 'dart:io'; 4 | import 'package:path/path.dart'; 5 | 6 | Future main() async { 7 | final input = Directory('../build/web'); 8 | final output = Directory('../../micropub/bin/static/'); 9 | await _copyDirectory(input, output); 10 | } 11 | 12 | Future _copyDirectory(Directory input, Directory output) async { 13 | if (!output.existsSync()) await output.create(); 14 | await for (var item in input.list()) { 15 | final targetPath = join(output.path, basename(item.path)); 16 | if (item is File) { 17 | await item.copy(targetPath); 18 | } else if (item is Directory) { 19 | await _copyDirectory(item, Directory(targetPath)); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /website/lib/views/package/widgets/content_entry.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:website/theme/theme.dart'; 3 | 4 | class ContentEntry extends StatelessWidget { 5 | const ContentEntry({ 6 | Key? key, 7 | required this.child, 8 | }) : super(key: key); 9 | 10 | final Widget child; 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | final theme = AppTheme.of(context); 15 | return Container( 16 | color: theme.color.bodyBackground, 17 | child: Center( 18 | child: ConstrainedBox( 19 | constraints: BoxConstraints(maxWidth: theme.size.maxWidth), 20 | child: SizedBox( 21 | width: double.infinity, 22 | child: child, 23 | ), 24 | ), 25 | ), 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /website/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "website", 9 | "request": "launch", 10 | "type": "dart" 11 | }, 12 | { 13 | "name": "website (profile mode)", 14 | "request": "launch", 15 | "type": "dart", 16 | "flutterMode": "profile" 17 | }, 18 | { 19 | "name": "website (release mode)", 20 | "request": "launch", 21 | "type": "dart", 22 | "flutterMode": "release" 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /micropub/lib/src/server/storage/storage.dart: -------------------------------------------------------------------------------- 1 | import 'package:micropub/src/shared/model.dart'; 2 | 3 | abstract class MicropubStorage { 4 | const MicropubStorage(); 5 | 6 | Future queryPackage(String name); 7 | 8 | Future addVersion(String name, MicropubVersion version); 9 | 10 | Future addUploader(String name, String email); 11 | 12 | Future removeUploader(String name, String email); 13 | 14 | void increaseDownloads(String name, String version); 15 | 16 | Future queryPackages({ 17 | required int size, 18 | required int page, 19 | required String sort, 20 | String? keyword, 21 | String? uploader, 22 | String? dependency, 23 | }); 24 | 25 | Stream> download(String name, String version); 26 | 27 | Future upload(String name, String version, List content); 28 | } 29 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | **/ios/Flutter/.last_build_id 26 | .dart_tool/ 27 | .flutter-plugins 28 | .flutter-plugins-dependencies 29 | .packages 30 | .pub-cache/ 31 | .pub/ 32 | /build/ 33 | 34 | # Web related 35 | lib/generated_plugin_registrant.dart 36 | 37 | # Symbolication related 38 | app.*.symbols 39 | 40 | # Obfuscation related 41 | app.*.map.json 42 | 43 | # Android Studio will place build artifacts here 44 | /android/app/debug 45 | /android/app/profile 46 | /android/app/release 47 | -------------------------------------------------------------------------------- /website/lib/widgets/footer.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:url_launcher/url_launcher.dart'; 3 | import 'package:website/theme/theme.dart'; 4 | 5 | import 'text_button.dart'; 6 | 7 | class AppFooter extends StatelessWidget { 8 | const AppFooter({ 9 | Key? key, 10 | }) : super(key: key); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | final theme = AppTheme.of(context); 15 | return DefaultTextStyle( 16 | style: theme.typography.title3.copyWith( 17 | color: theme.color.barBarText1, 18 | ), 19 | child: Container( 20 | color: theme.color.barBarBackground, 21 | padding: EdgeInsets.all(theme.spacing.regular), 22 | child: Center( 23 | child: AppTextButton( 24 | title: 'micropub from Github', 25 | onTap: () { 26 | launch('https://github.com/aloisdeniel/micropub'); 27 | }, 28 | ), 29 | ), 30 | ), 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /micropub/lib/src/server/controllers/api/packages.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'packages.dart'; 4 | 5 | // ************************************************************************** 6 | // ShelfRouterGenerator 7 | // ************************************************************************** 8 | 9 | Router _$PackagesApiControllerRouter(PackagesApiController service) { 10 | final router = Router(); 11 | router.add('GET', r'/', service.getVersions); 12 | router.add('GET', r'//details', service.getPackageDetails); 13 | router.add('GET', r'//versions/', service.getVersion); 14 | router.add('GET', r'/', service.getPackages); 15 | router.add('GET', r'/versions/new', service.getUploadUrl); 16 | router.add('POST', r'/versions/newUpload', service.upload); 17 | router.add('GET', r'/versions/newUploadFinish', service.uploadFinish); 18 | router.add('POST', r'//uploaders', service.addUploader); 19 | router.add('DELETE', r'//uploaders/', service.removeUploader); 20 | return router; 21 | } 22 | -------------------------------------------------------------------------------- /website/web/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website", 3 | "short_name": "website", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "background_color": "#0175C2", 7 | "theme_color": "#0175C2", 8 | "description": "A new Flutter project.", 9 | "orientation": "portrait-primary", 10 | "prefer_related_applications": false, 11 | "icons": [ 12 | { 13 | "src": "icons/Icon-192.png", 14 | "sizes": "192x192", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "icons/Icon-512.png", 19 | "sizes": "512x512", 20 | "type": "image/png" 21 | }, 22 | { 23 | "src": "icons/Icon-maskable-192.png", 24 | "sizes": "192x192", 25 | "type": "image/png", 26 | "purpose": "maskable" 27 | }, 28 | { 29 | "src": "icons/Icon-maskable-512.png", 30 | "sizes": "512x512", 31 | "type": "image/png", 32 | "purpose": "maskable" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /website/lib/app.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | 4 | import 'routing.dart'; 5 | import 'state/notifier.dart'; 6 | import 'theme/theme.dart'; 7 | 8 | class MicropubWebsite extends StatelessWidget { 9 | const MicropubWebsite({Key? key}) : super(key: key); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return AppTheme( 14 | child: ChangeNotifierProvider( 15 | create: (context) => AppStateNotifier(), 16 | child: Builder(builder: (context) { 17 | return AppRouting( 18 | notifier: context.watch(), 19 | builder: (context, routerDelegate, routeInformationParser) { 20 | return MaterialApp.router( 21 | title: 'Micropub', 22 | debugShowCheckedModeBanner: false, 23 | routeInformationParser: routeInformationParser, 24 | routerDelegate: routerDelegate, 25 | ); 26 | }, 27 | ); 28 | }), 29 | ), 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /website/lib/theme/data/data.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:website/theme/data/spacing.dart'; 3 | 4 | import 'colors.dart'; 5 | import 'radius.dart'; 6 | import 'sizes.dart'; 7 | import 'typography.dart'; 8 | 9 | class AppThemeData extends Equatable { 10 | const AppThemeData({ 11 | required this.color, 12 | required this.typography, 13 | required this.spacing, 14 | required this.size, 15 | required this.radius, 16 | }); 17 | 18 | AppThemeData.fallback() 19 | : color = const ColorData.light(), 20 | typography = TypographyData.regular(), 21 | spacing = const SpacingData.regular(), 22 | radius = const RadiusData.regular(), 23 | size = const SizesData.regular(); 24 | 25 | final ColorData color; 26 | final TypographyData typography; 27 | final SpacingData spacing; 28 | final SizesData size; 29 | final RadiusData radius; 30 | 31 | @override 32 | List get props => [ 33 | color, 34 | typography, 35 | spacing, 36 | size, 37 | radius, 38 | ]; 39 | } 40 | -------------------------------------------------------------------------------- /micropub/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Aloïs Deniel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /micropub/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the static analysis results for your project (errors, 2 | # warnings, and lints). 3 | # 4 | # This enables the 'recommended' set of lints from `package:lints`. 5 | # This set helps identify many issues that may lead to problems when running 6 | # or consuming Dart code, and enforces writing Dart using a single, idiomatic 7 | # style and format. 8 | # 9 | # If you want a smaller set of lints you can change this to specify 10 | # 'package:lints/core.yaml'. These are just the most critical lints 11 | # (the recommended set includes the core lints). 12 | # The core lints are also what is used by pub.dev for scoring packages. 13 | 14 | include: package:lints/recommended.yaml 15 | 16 | # Uncomment the following section to specify additional rules. 17 | 18 | # linter: 19 | # rules: 20 | # - camel_case_types 21 | 22 | # analyzer: 23 | # exclude: 24 | # - path/to/excluded/files/** 25 | 26 | # For more information about the core and recommended set of lints, see 27 | # https://dart.dev/go/core-lints 28 | 29 | # For additional information about configuring this file, see 30 | # https://dart.dev/guides/language/analysis-options 31 | -------------------------------------------------------------------------------- /micropub/lib/src/server/options.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'options.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$_MicropubOptions _$$_MicropubOptionsFromJson(Map json) => 10 | _$_MicropubOptions( 11 | adminKey: json['adminKey'] as String, 12 | adminEmail: json['adminEmail'] as String, 13 | host: json['host'] as String, 14 | port: json['port'] as int, 15 | directory: json['directory'] as String? ?? '.', 16 | name: json['name'] as String?, 17 | distantHostUrl: json['distantHostUrl'] as String?, 18 | sslCert: json['sslCert'] as String?, 19 | sslKey: json['sslKey'] as String?, 20 | ); 21 | 22 | Map _$$_MicropubOptionsToJson(_$_MicropubOptions instance) => 23 | { 24 | 'adminKey': instance.adminKey, 25 | 'adminEmail': instance.adminEmail, 26 | 'host': instance.host, 27 | 'port': instance.port, 28 | 'directory': instance.directory, 29 | 'name': instance.name, 30 | 'distantHostUrl': instance.distantHostUrl, 31 | 'sslCert': instance.sslCert, 32 | 'sslKey': instance.sslKey, 33 | }; 34 | -------------------------------------------------------------------------------- /micropub/lib/src/server/controllers/api/download.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'package:micropub/src/server/controllers/api/api.dart'; 3 | import 'package:micropub/src/shared/model.dart'; 4 | import 'package:micropub/src/server/storage/storage.dart'; 5 | import 'package:shelf/shelf.dart' as shelf; 6 | import 'package:shelf_router/shelf_router.dart'; 7 | 8 | part 'download.g.dart'; 9 | 10 | class DownloadController { 11 | const DownloadController({ 12 | required this.storage, 13 | }); 14 | 15 | final MicropubStorage storage; 16 | 17 | Router get router => _$DownloadControllerRouter(this); 18 | 19 | bool isPubClient(shelf.Request req) { 20 | var ua = req.headers[HttpHeaders.userAgentHeader]; 21 | return ua != null && ua.toLowerCase().contains('dart pub'); 22 | } 23 | 24 | @Route.get('//versions/.tar.gz') 25 | Future download( 26 | shelf.Request req, String name, String version) async { 27 | return req.withAuthorizations(MicropubAuthorization.read, () async { 28 | if (isPubClient(req)) { 29 | storage.increaseDownloads(name, version); 30 | } 31 | 32 | final result = storage.download(name, version); 33 | 34 | return shelf.Response.ok( 35 | result, 36 | headers: {HttpHeaders.contentTypeHeader: ContentType.binary.mimeType}, 37 | ); 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /website/lib/views/package/widgets/tabs/readme.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:micropub/client.dart'; 3 | import 'package:sliver_tools/sliver_tools.dart'; 4 | import 'package:website/theme/theme.dart'; 5 | import 'package:website/views/package/widgets/content_entry.dart'; 6 | import 'package:website/widgets/sliver_markdown.dart'; 7 | 8 | class ReadmeTab extends StatelessWidget { 9 | const ReadmeTab({ 10 | Key? key, 11 | required this.package, 12 | }) : super( 13 | key: key, 14 | ); 15 | 16 | final MicropubPackageDetails package; 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | final theme = AppTheme.of(context); 21 | 22 | return SliverStack( 23 | children: [ 24 | SliverPositioned.fill( 25 | child: Container( 26 | decoration: BoxDecoration( 27 | color: theme.color.bodyBackground, 28 | ), 29 | ), 30 | ), 31 | SliverMarkdown( 32 | data: package.package.versions.first.readme ?? 'No readme', 33 | padding: EdgeInsets.all(theme.spacing.small).copyWith( 34 | bottom: theme.spacing.extraBig, 35 | ), 36 | itemBuilder: (context, child) => ContentEntry( 37 | child: child, 38 | ), 39 | ), 40 | ], 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /website/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at 17 | # https://dart-lang.github.io/linter/lints/index.html. 18 | # 19 | # Instead of disabling a lint rule for the entire project in the 20 | # section below, it can also be suppressed for a single line of code 21 | # or a specific dart file by using the `// ignore: name_of_lint` and 22 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 23 | # producing the lint. 24 | rules: 25 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 26 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 27 | 28 | # Additional information about this file can be found at 29 | # https://dart.dev/guides/language/analysis-options 30 | -------------------------------------------------------------------------------- /micropub/lib/src/server/auth/hive.dart: -------------------------------------------------------------------------------- 1 | import 'package:micropub/src/server/auth/auth.dart'; 2 | import 'package:micropub/src/server/options.dart'; 3 | import 'package:micropub/src/shared/model.dart'; 4 | import 'package:hive/hive.dart'; 5 | import 'package:uuid/uuid.dart'; 6 | 7 | var uuid = Uuid(); 8 | 9 | class MicropubHiveAuth extends MicropubAuth { 10 | MicropubHiveAuth({ 11 | required MicropubOptions options, 12 | }) : super(options); 13 | 14 | final box = Hive.box('auth.micropub'); 15 | 16 | @override 17 | Future getAccessKey(String key) async { 18 | if (!box.containsKey(key)) { 19 | return null; 20 | } 21 | 22 | return MicropubAccessKey.fromJson(box.get(key)); 23 | } 24 | 25 | @override 26 | Future createKey({ 27 | required String email, 28 | required List authorizations, 29 | }) async { 30 | final key = uuid.v4(); 31 | final accessKey = MicropubAccessKey( 32 | id: uuid.v4(), 33 | key: key, 34 | email: email, 35 | authorizations: authorizations, 36 | creationDate: DateTime.now(), 37 | ); 38 | await box.put(key, accessKey.toJson()); 39 | return accessKey; 40 | } 41 | 42 | @override 43 | Future revokeKey(String key) { 44 | final deleted = box.values 45 | .map((x) => MicropubAccessKey.fromJson({...x})) 46 | .firstWhere((x) => x.id == key); 47 | return box.delete(deleted.key); 48 | } 49 | 50 | @override 51 | Future> getAllAccessKeys() { 52 | return Future.value( 53 | box.values.map((x) => MicropubAccessKey.fromJson({...x})).toList()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /website/lib/theme/data/typography.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | class TypographyData extends Equatable { 5 | const TypographyData({ 6 | required this.title1, 7 | required this.title2, 8 | required this.title3, 9 | required this.paragraph1, 10 | required this.paragraph2, 11 | required this.paragraph3, 12 | required this.paragraph4, 13 | }); 14 | 15 | const TypographyData.regular() 16 | : title1 = const TextStyle( 17 | fontFamily: 'Roboto', 18 | fontSize: 64, 19 | ), 20 | title2 = const TextStyle( 21 | fontFamily: 'Roboto', 22 | fontSize: 36, 23 | ), 24 | title3 = const TextStyle( 25 | fontFamily: 'Roboto', 26 | fontSize: 20, 27 | ), 28 | paragraph1 = const TextStyle( 29 | fontFamily: 'Roboto', 30 | fontSize: 18, 31 | ), 32 | paragraph2 = const TextStyle( 33 | fontFamily: 'Roboto', 34 | fontSize: 16, 35 | ), 36 | paragraph3 = const TextStyle( 37 | fontFamily: 'Roboto', 38 | fontSize: 12, 39 | ), 40 | paragraph4 = const TextStyle( 41 | fontFamily: 'Roboto', 42 | fontSize: 8, 43 | ); 44 | 45 | final TextStyle title1; 46 | final TextStyle title2; 47 | final TextStyle title3; 48 | final TextStyle paragraph1; 49 | final TextStyle paragraph2; 50 | final TextStyle paragraph3; 51 | final TextStyle paragraph4; 52 | 53 | @override 54 | List get props => [ 55 | title1, 56 | title2, 57 | title3, 58 | paragraph1, 59 | paragraph2, 60 | paragraph3, 61 | paragraph4, 62 | ]; 63 | } 64 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | 2 | 3 | outputdir='./micropub' 4 | version=$1 5 | base_download_uri=https://github.com/aloisdeniel/micropub/releases/download/$version; 6 | 7 | echo "[micropub]" 8 | 9 | if [ -z "$1" ] 10 | then 11 | echo "Missing version as first argument" 12 | fi 13 | 14 | echo "Installing of v$version starts..." 15 | echo "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" 16 | 17 | # Create dir if not exists 18 | mkdir -p $outputdir 19 | 20 | # Delete previous version if exists 21 | echo "" 22 | if [[ -e $outputdir/micropub.exe ]]; then 23 | echo " ~x~ Deleting micropub.exe" 24 | rm -r $outputdir/micropub.exe 25 | fi 26 | if [[ -e $outputdir/static.zip ]]; then 27 | echo " ~x~ Deleting static.zip" 28 | rm -r $outputdir/static.zip 29 | fi 30 | if [[ -e $outputdir/static ]]; then 31 | echo " ~x~ Deleting static" 32 | rm -r $outputdir/static 33 | fi 34 | 35 | 36 | echo "" 37 | echo " <~~ Downloading static.zip" 38 | curl -L $base_download_uri/static.zip -o $outputdir/static.zip 39 | 40 | echo "" 41 | echo " <^> Unzipping static.zip" 42 | unzip $outputdir/static.zip -d $outputdir/static 43 | echo "" 44 | 45 | if [[ "$OSTYPE" == "darwin"* ]]; then 46 | echo " <~~ Downloading micropub.exe for macOS" 47 | curl -L $base_download_uri/micropub-macos.exe -o $outputdir/micropub.exe 48 | elif [[ "$OSTYPE" == "cygwin" ]]; then 49 | echo " <~~ Downloading micropub.exe for Windows" 50 | curl -L $base_download_uri/micropub-windows.exe -o $outputdir/micropub.exe 51 | else 52 | echo " <~~ Downloading micropub.exe for Ubuntu" 53 | curl -L $base_download_uri/micropub-ubuntu.exe -o $outputdir/micropub.exe 54 | fi 55 | 56 | echo "" 57 | echo " ~x~ Deleting static.zip" 58 | rm -r $outputdir/static.zip 59 | 60 | echo "" 61 | echo "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" 62 | echo "[✓] Installed!" -------------------------------------------------------------------------------- /website/lib/views/packages/widgets/result_overview.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:gap/gap.dart'; 3 | import 'package:website/theme/theme.dart'; 4 | 5 | class PackagesResultOverview extends StatelessWidget { 6 | const PackagesResultOverview({ 7 | Key? key, 8 | required this.count, 9 | }) : super(key: key); 10 | 11 | final int count; 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | final theme = AppTheme.of(context); 16 | return DecoratedBox( 17 | decoration: BoxDecoration( 18 | color: theme.color.bodyBackground, 19 | ), 20 | child: Center( 21 | child: ConstrainedBox( 22 | constraints: BoxConstraints(maxWidth: theme.size.maxWidth), 23 | child: Padding( 24 | padding: EdgeInsets.symmetric( 25 | horizontal: theme.spacing.big, 26 | vertical: theme.spacing.regular, 27 | ), 28 | child: Row( 29 | children: [ 30 | Text( 31 | 'RESULTS', 32 | style: theme.typography.paragraph3, 33 | ), 34 | const Gap(4), 35 | Container( 36 | color: theme.color.bodyText1.withOpacity(0.1), 37 | padding: const EdgeInsets.symmetric( 38 | vertical: 4, 39 | horizontal: 4, 40 | ), 41 | child: Text( 42 | '$count', 43 | style: theme.typography.paragraph3, 44 | ), 45 | ), 46 | const Gap(4), 47 | Text( 48 | 'package${count > 1 ? 's' : ''}', 49 | style: theme.typography.paragraph3, 50 | ), 51 | ], 52 | ), 53 | ), 54 | ), 55 | ), 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /website/lib/widgets/text_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:tap_builder/tap_builder.dart'; 3 | import 'package:website/theme/theme.dart'; 4 | 5 | class AppTextButton extends StatelessWidget { 6 | const AppTextButton({ 7 | Key? key, 8 | required this.title, 9 | this.onTap, 10 | this.style, 11 | this.color, 12 | this.padding, 13 | }) : super(key: key); 14 | 15 | final Color? color; 16 | final TextStyle? style; 17 | final String title; 18 | final EdgeInsets? padding; 19 | final VoidCallback? onTap; 20 | 21 | @override 22 | Widget build(BuildContext context) { 23 | final theme = AppTheme.of(context); 24 | final color = this.color ?? theme.color.bodyAccentText1; 25 | final style = this.style ?? theme.typography.paragraph2; 26 | final padding = this.padding ?? 27 | EdgeInsets.symmetric( 28 | horizontal: theme.spacing.regular, 29 | vertical: theme.spacing.small, 30 | ); 31 | return TapBuilder( 32 | onTap: onTap, 33 | builder: (BuildContext context, TapState state, bool isFocused) { 34 | final opacity = () { 35 | switch (state) { 36 | case TapState.pressed: 37 | return 0.2; 38 | case TapState.hover: 39 | return 0.1; 40 | default: 41 | return 0.0; 42 | } 43 | }(); 44 | return AnimatedContainer( 45 | duration: const Duration(milliseconds: 200), 46 | decoration: BoxDecoration( 47 | color: color.withOpacity(opacity), 48 | borderRadius: BorderRadius.all(theme.radius.regular), 49 | ), 50 | padding: padding, 51 | child: Text( 52 | title, 53 | style: style.copyWith( 54 | color: color, 55 | ), 56 | ), 57 | ); 58 | }, 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /micropub/lib/src/server/options.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:freezed_annotation/freezed_annotation.dart'; 5 | 6 | part 'options.freezed.dart'; 7 | part 'options.g.dart'; 8 | 9 | @Freezed() 10 | class MicropubOptions with _$MicropubOptions { 11 | const MicropubOptions._(); 12 | const factory MicropubOptions({ 13 | required String adminKey, 14 | required String adminEmail, 15 | required String host, 16 | required int port, 17 | @Default('.') String directory, 18 | String? name, 19 | String? distantHostUrl, 20 | String? sslCert, 21 | String? sslKey, 22 | }) = _MicropubOptions; 23 | 24 | factory MicropubOptions.fromEnv() { 25 | String? optionalKey(String key) { 26 | return Platform.environment['MICROPUB_$key']; 27 | } 28 | 29 | String key(String key, [String? defaultValue]) { 30 | key = 'MICROPUB_$key'; 31 | if (!Platform.environment.containsKey(key)) { 32 | if (defaultValue == null) { 33 | throw Exception('Missing $key environment key'); 34 | } 35 | return defaultValue; 36 | } 37 | return Platform.environment[key]!; 38 | } 39 | 40 | return MicropubOptions( 41 | directory: key('DIRECTORY', '.'), 42 | adminKey: key('ADMIN_KEY'), 43 | adminEmail: key('ADMIN_EMAIL'), 44 | host: key('HOST'), 45 | port: int.parse( 46 | key('PORT'), 47 | ), 48 | name: optionalKey('NAME'), 49 | distantHostUrl: optionalKey('DISTANT_HOST_URL'), 50 | sslCert: optionalKey('SSL_CERT'), 51 | sslKey: optionalKey('SSL_KEY'), 52 | ); 53 | } 54 | 55 | static Future fromFile(File file) async { 56 | final content = await file.readAsString(); 57 | return MicropubOptions.fromJson(jsonDecode(content)); 58 | } 59 | 60 | factory MicropubOptions.fromJson(Map map) => 61 | _$MicropubOptionsFromJson(map); 62 | } 63 | -------------------------------------------------------------------------------- /website/lib/views/admin/widgets/revoke_key_dialog.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:provider/provider.dart'; 5 | import 'package:micropub/client.dart'; 6 | import 'package:website/state/notifier.dart'; 7 | 8 | class RevokeKeyDialog extends StatefulWidget { 9 | const RevokeKeyDialog({ 10 | Key? key, 11 | required this.accessKey, 12 | }) : super(key: key); 13 | 14 | final MicropubAccessKey accessKey; 15 | 16 | static Future show(BuildContext context, MicropubAccessKey accessKey) { 17 | return showDialog( 18 | context: context, 19 | builder: (context) { 20 | return RevokeKeyDialog(accessKey: accessKey); 21 | }, 22 | ); 23 | } 24 | 25 | @override 26 | State createState() => _RevokeKeyDialogState(); 27 | } 28 | 29 | class _RevokeKeyDialogState extends State { 30 | var isLoading = false; 31 | 32 | @override 33 | Widget build(BuildContext context) { 34 | return AlertDialog( 35 | title: const Text('Are you sure?'), 36 | content: 37 | Text('You\'re about to delete "${widget.accessKey.email}"\'s key.'), 38 | actions: [ 39 | TextButton( 40 | child: const Text('Cancel'), 41 | onPressed: () => Navigator.pop(context), 42 | ), 43 | TextButton( 44 | child: const Text('Delete'), 45 | onPressed: () async { 46 | final notifier = context.read(); 47 | setState(() { 48 | isLoading = true; 49 | }); 50 | try { 51 | await notifier.revokeAccessKey(widget.accessKey); 52 | Navigator.pop(context); 53 | } catch (e) { 54 | setState(() { 55 | isLoading = false; 56 | }); 57 | } 58 | unawaited(notifier.refreshAdmin()); 59 | }, 60 | ) 61 | ], 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /website/lib/views/admin/widgets/action_bar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:gap/gap.dart'; 3 | import 'package:website/theme/theme.dart'; 4 | import 'package:website/widgets/flat_button.dart'; 5 | 6 | import 'create_key_dialog.dart'; 7 | 8 | class AdminActionBar extends StatelessWidget { 9 | const AdminActionBar({ 10 | Key? key, 11 | required this.count, 12 | }) : super(key: key); 13 | 14 | final int count; 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | final theme = AppTheme.of(context); 19 | return DecoratedBox( 20 | decoration: BoxDecoration( 21 | color: theme.color.bodyBackground, 22 | ), 23 | child: Center( 24 | child: ConstrainedBox( 25 | constraints: BoxConstraints(maxWidth: theme.size.maxWidth), 26 | child: Padding( 27 | padding: EdgeInsets.symmetric( 28 | horizontal: theme.spacing.big, 29 | vertical: theme.spacing.regular, 30 | ), 31 | child: Row( 32 | children: [ 33 | Container( 34 | color: theme.color.bodyText1.withOpacity(0.1), 35 | padding: const EdgeInsets.symmetric( 36 | vertical: 4, 37 | horizontal: 4, 38 | ), 39 | child: Text( 40 | count.toString(), 41 | style: theme.typography.paragraph3, 42 | ), 43 | ), 44 | const Gap(4), 45 | Text( 46 | 'PRIVATE ACCESS KEY${count > 1 ? 'S' : ''}', 47 | style: theme.typography.paragraph3, 48 | ), 49 | const Spacer(), 50 | AppFlatButton( 51 | title: 'Create a new access key', 52 | onTap: () => CreateAccessKeyDialog.show(context), 53 | ), 54 | ], 55 | ), 56 | ), 57 | ), 58 | ), 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /micropub/lib/src/server/auth/auth.dart: -------------------------------------------------------------------------------- 1 | import 'package:logging/logging.dart'; 2 | import 'package:micropub/server.dart'; 3 | import 'package:shelf/shelf.dart'; 4 | 5 | abstract class MicropubAuth { 6 | const MicropubAuth(this.options); 7 | 8 | final MicropubOptions options; 9 | 10 | Future getAccessKey(String key); 11 | 12 | Future> getAllAccessKeys(); 13 | 14 | Future createKey({ 15 | required String email, 16 | required List authorizations, 17 | }); 18 | 19 | Future revokeKey(String key); 20 | 21 | static Handler authenticated(Handler innerHandler) { 22 | return (Request request) async { 23 | request.context['auth'] = true; 24 | return innerHandler(request); 25 | }; 26 | } 27 | 28 | Handler middleware(Handler innerHandler) { 29 | return (Request request) async { 30 | Logger.root.info('[Auth]'); 31 | final header = request.headers['authorization']; 32 | if (header != null && header.toLowerCase().startsWith('bearer ')) { 33 | final key = header.substring(7); 34 | Logger.root.info(' Authorization header found'); 35 | if (key == options.adminKey) { 36 | return innerHandler( 37 | request.change( 38 | context: { 39 | ...request.context, 40 | 'email': options.adminEmail, 41 | 'authorizations': [ 42 | MicropubAuthorization.admin, 43 | MicropubAuthorization.read, 44 | MicropubAuthorization.write, 45 | ], 46 | }, 47 | ), 48 | ); 49 | } 50 | final accessKey = await getAccessKey(key); 51 | if (accessKey != null) { 52 | return innerHandler( 53 | request.change( 54 | context: { 55 | ...request.context, 56 | 'email': accessKey.email, 57 | 'authorizations': accessKey.authorizations, 58 | }, 59 | ), 60 | ); 61 | } 62 | } 63 | 64 | return Response.forbidden('Not authorized'); 65 | }; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /website/lib/views/package/widgets/tabs/versions.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:micropub/client.dart'; 3 | import 'package:website/theme/theme.dart'; 4 | import 'package:website/views/package/widgets/content_entry.dart'; 5 | import 'package:timeago/timeago.dart' as timeago; 6 | 7 | class VersionsTab extends StatelessWidget { 8 | const VersionsTab({ 9 | Key? key, 10 | required this.package, 11 | }) : super(key: key); 12 | 13 | final MicropubPackageDetails package; 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | final theme = AppTheme.of(context); 18 | return SliverList( 19 | delegate: SliverChildListDelegate([ 20 | ...package.package.versions.map( 21 | (e) => VersionTile( 22 | version: e, 23 | ), 24 | ), 25 | ContentEntry( 26 | child: SizedBox( 27 | height: theme.spacing.extraBig, 28 | ), 29 | ), 30 | ]), 31 | ); 32 | } 33 | } 34 | 35 | class VersionTile extends StatelessWidget { 36 | const VersionTile({ 37 | Key? key, 38 | required this.version, 39 | }) : super(key: key); 40 | 41 | final MicropubVersion version; 42 | 43 | @override 44 | Widget build(BuildContext context) { 45 | final theme = AppTheme.of(context); 46 | final date = version.createdAt; 47 | return ContentEntry( 48 | child: Column( 49 | crossAxisAlignment: CrossAxisAlignment.stretch, 50 | mainAxisSize: MainAxisSize.min, 51 | children: [ 52 | Padding( 53 | padding: EdgeInsets.all(theme.spacing.small), 54 | child: Row( 55 | mainAxisSize: MainAxisSize.min, 56 | children: [ 57 | Expanded( 58 | child: Text( 59 | version.version, 60 | style: theme.typography.paragraph1, 61 | ), 62 | ), 63 | if (date != null) 64 | Text( 65 | timeago.format(date), 66 | style: theme.typography.paragraph2, 67 | ), 68 | ], 69 | ), 70 | ), 71 | Container( 72 | height: 1, 73 | color: theme.color.bodyText1.withOpacity(0.5), 74 | ) 75 | ], 76 | ), 77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /website/lib/views/package/widgets/tabs/installing.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:micropub/client.dart'; 3 | import 'package:provider/provider.dart'; 4 | import 'package:sliver_tools/sliver_tools.dart'; 5 | import 'package:website/state/notifier.dart'; 6 | import 'package:website/theme/theme.dart'; 7 | import 'package:website/views/package/widgets/content_entry.dart'; 8 | import 'package:website/widgets/sliver_markdown.dart'; 9 | 10 | class InstallingTab extends StatelessWidget { 11 | const InstallingTab({ 12 | Key? key, 13 | required this.package, 14 | }) : super( 15 | key: key, 16 | ); 17 | 18 | final MicropubPackageDetails package; 19 | 20 | String buildMarkdown(BuildContext context) { 21 | final distantHostUrl = context.select( 22 | (AppStateNotifier value) => value.value.map( 23 | initializing: (x) => null, 24 | initializationFailed: (x) => null, 25 | initialized: (x) => x.info.distantHostUrl, 26 | authenticated: (x) => x.info.distantHostUrl, 27 | authenticating: (x) => x.info.distantHostUrl, 28 | authenticationFailed: (x) => x.info.distantHostUrl, 29 | notAuthenticated: (x) => x.info.distantHostUrl, 30 | ), 31 | ); 32 | return ''' 33 | ## Use this package as a library 34 | 35 | Add an entry like this to your package's `pubspec.yaml` (and run an implicit `dart pub get`): 36 | 37 | ```yaml 38 | dependencies: 39 | ${package.package.name}: 40 | hosted: $distantHostUrl 41 | version: ^${package.package.versions.first.version} 42 | ``` 43 | 44 | > Make sure to point to the micropub host. 45 | '''; 46 | } 47 | 48 | @override 49 | Widget build(BuildContext context) { 50 | final theme = AppTheme.of(context); 51 | return SliverStack( 52 | children: [ 53 | SliverPositioned.fill( 54 | child: Container( 55 | decoration: BoxDecoration( 56 | color: theme.color.bodyBackground, 57 | ), 58 | ), 59 | ), 60 | SliverMarkdown( 61 | data: buildMarkdown(context), 62 | padding: EdgeInsets.all(theme.spacing.small).copyWith( 63 | bottom: theme.spacing.extraBig, 64 | ), 65 | itemBuilder: (context, child) => ContentEntry( 66 | child: child, 67 | ), 68 | ) 69 | ], 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /micropub/lib/src/server/controllers/api/admin.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'package:micropub/src/server/auth/auth.dart'; 3 | import 'package:micropub/src/server/controllers/api/api.dart'; 4 | import 'package:micropub/src/shared/model.dart'; 5 | import 'package:shelf/shelf.dart' as shelf; 6 | import 'package:shelf_router/shelf_router.dart'; 7 | 8 | part 'admin.g.dart'; 9 | 10 | class AdminApiController { 11 | const AdminApiController({ 12 | required this.auth, 13 | }); 14 | 15 | final MicropubAuth auth; 16 | 17 | Router get router => _$AdminApiControllerRouter(this); 18 | 19 | @Route.get('/users') 20 | Future getAccessKeys(shelf.Request req) async { 21 | return req.withAuthorizations(MicropubAuthorization.admin, () async { 22 | final keys = await auth.getAllAccessKeys(); 23 | keys.sort((a, b) => b.creationDate.compareTo(a.creationDate)); 24 | 25 | return [ 26 | // Hiding everything execpt the first 3 chars 27 | ...keys.map((e) => e.copyWith( 28 | key: e.key.replaceRange( 29 | 3, 30 | e.key.length, 31 | 'X' * (e.key.length - 3), 32 | ), 33 | )) 34 | ].asJsonResponse(); 35 | }); 36 | } 37 | 38 | @Route.post('/users') 39 | Future createAccessKey(shelf.Request req) async { 40 | return req.withAuthorizations(MicropubAuthorization.admin, () async { 41 | final body = await req.readAsString(); 42 | final decoded = jsonDecode(body); 43 | 44 | final email = decoded['email'] as String?; 45 | final authorizations = decoded['authorizations'] as List?; 46 | 47 | if (email == null || email.trim().isEmpty) { 48 | throw Exception('Provided email is empty'); 49 | } 50 | 51 | final result = await auth.createKey( 52 | email: email, 53 | authorizations: [ 54 | ...(authorizations ?? ['read']) 55 | .cast() 56 | .map(micropubAuthorizationFromString), 57 | ], 58 | ); 59 | 60 | return result.toJson().asJsonResponse(); 61 | }); 62 | } 63 | 64 | @Route.delete('/users/') 65 | Future revokeAccessKey(shelf.Request req, String key) async { 66 | return req.withAuthorizations(MicropubAuthorization.admin, () async { 67 | await auth.revokeKey(key); 68 | return true.asJsonResponse(); 69 | }); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /website/lib/views/package/widgets/tabs/dependencies.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:micropub/client.dart'; 3 | import 'package:website/theme/theme.dart'; 4 | import 'package:website/views/package/widgets/content_entry.dart'; 5 | 6 | class DependenciesTab extends StatelessWidget { 7 | const DependenciesTab({ 8 | Key? key, 9 | required this.package, 10 | }) : super(key: key); 11 | 12 | final MicropubPackageDetails package; 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final theme = AppTheme.of(context); 17 | final dependencies = package.package.versions.last.pubspec['depdencies'] 18 | as Map? ?? 19 | const {}; 20 | return SliverList( 21 | delegate: SliverChildListDelegate([ 22 | if (dependencies.isEmpty) 23 | ContentEntry( 24 | child: Text( 25 | 'No dependencies', 26 | style: theme.typography.paragraph2, 27 | ), 28 | ), 29 | ...dependencies.entries.map( 30 | (e) => DependencyTile( 31 | name: e.key, 32 | value: e.value, 33 | ), 34 | ), 35 | ContentEntry( 36 | child: SizedBox( 37 | height: theme.spacing.extraBig, 38 | ), 39 | ), 40 | ]), 41 | ); 42 | } 43 | } 44 | 45 | class DependencyTile extends StatelessWidget { 46 | const DependencyTile({ 47 | Key? key, 48 | required this.name, 49 | required this.value, 50 | }) : super(key: key); 51 | 52 | final String name; 53 | final dynamic value; 54 | 55 | @override 56 | Widget build(BuildContext context) { 57 | final theme = AppTheme.of(context); 58 | return ContentEntry( 59 | child: Column( 60 | crossAxisAlignment: CrossAxisAlignment.stretch, 61 | mainAxisSize: MainAxisSize.min, 62 | children: [ 63 | Padding( 64 | padding: EdgeInsets.all(theme.spacing.small), 65 | child: Row( 66 | mainAxisSize: MainAxisSize.min, 67 | children: [ 68 | Expanded( 69 | child: Text( 70 | name, 71 | style: theme.typography.paragraph1, 72 | ), 73 | ), 74 | ], 75 | ), 76 | ), 77 | Container( 78 | height: 1, 79 | color: theme.color.bodyText1.withOpacity(0.5), 80 | ) 81 | ], 82 | ), 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /website/lib/widgets/flat_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:tap_builder/tap_builder.dart'; 3 | import 'package:website/theme/theme.dart'; 4 | 5 | class AppFlatButton extends StatelessWidget { 6 | const AppFlatButton({ 7 | Key? key, 8 | required this.title, 9 | this.onTap, 10 | this.style, 11 | this.background, 12 | this.foreground, 13 | this.padding, 14 | }) : super(key: key); 15 | 16 | final Color? background; 17 | final Color? foreground; 18 | final TextStyle? style; 19 | final String title; 20 | final EdgeInsets? padding; 21 | final VoidCallback? onTap; 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | final theme = AppTheme.of(context); 26 | final background = this.background ?? theme.color.primaryButtonBackground; 27 | final foreground = this.foreground ?? theme.color.primaryButtonForeground; 28 | final style = this.style ?? theme.typography.paragraph2; 29 | final padding = this.padding ?? 30 | EdgeInsets.symmetric( 31 | horizontal: theme.spacing.regular, 32 | vertical: theme.spacing.small, 33 | ); 34 | return TapBuilder( 35 | onTap: onTap, 36 | builder: (BuildContext context, TapState state, bool isFocused) { 37 | final foregroundOpacity = () { 38 | switch (state) { 39 | case TapState.pressed: 40 | return 0.4; 41 | case TapState.hover: 42 | return 0.25; 43 | default: 44 | return 0.0; 45 | } 46 | }(); 47 | 48 | final opacity = () { 49 | switch (state) { 50 | case TapState.disabled: 51 | return 0.30; 52 | default: 53 | return 1.0; 54 | } 55 | }(); 56 | return AnimatedOpacity( 57 | duration: const Duration(milliseconds: 200), 58 | opacity: opacity, 59 | child: Container( 60 | decoration: BoxDecoration( 61 | color: background, 62 | borderRadius: BorderRadius.all(theme.radius.regular), 63 | ), 64 | child: AnimatedContainer( 65 | duration: const Duration(milliseconds: 200), 66 | color: foreground.withOpacity(foregroundOpacity), 67 | padding: padding, 68 | child: Text( 69 | title, 70 | style: style.copyWith( 71 | color: foreground, 72 | ), 73 | ), 74 | ), 75 | ), 76 | ); 77 | }, 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /website/lib/views/authentication/widgets/password_field.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:website/theme/theme.dart'; 3 | 4 | class PasswordField extends StatefulWidget { 5 | const PasswordField({ 6 | Key? key, 7 | required this.initial, 8 | required this.onChanged, 9 | required this.onSubmitted, 10 | }) : super(key: key); 11 | 12 | final String initial; 13 | final ValueChanged onChanged; 14 | final ValueChanged onSubmitted; 15 | 16 | @override 17 | State createState() => _PasswordFieldState(); 18 | } 19 | 20 | class _PasswordFieldState extends State { 21 | late final controller = TextEditingController( 22 | text: widget.initial, 23 | ); 24 | 25 | @override 26 | void dispose() { 27 | controller.dispose(); 28 | super.dispose(); 29 | } 30 | 31 | @override 32 | Widget build(BuildContext context) { 33 | final theme = AppTheme.of(context); 34 | return Container( 35 | decoration: BoxDecoration( 36 | color: theme.color.heroBarFieldBackground, 37 | borderRadius: BorderRadius.all(theme.radius.regular), 38 | ), 39 | child: Material( 40 | color: Colors.transparent, 41 | child: TextField( 42 | onChanged: widget.onChanged, 43 | onSubmitted: widget.onSubmitted, 44 | controller: controller, 45 | style: theme.typography.paragraph1.copyWith( 46 | color: theme.color.heroBarFieldText1, 47 | ), 48 | decoration: InputDecoration( 49 | hintText: 'Enter your access key', 50 | hintStyle: theme.typography.paragraph1.copyWith( 51 | color: theme.color.heroBarFieldPlaceholder, 52 | ), 53 | border: const OutlineInputBorder( 54 | borderSide: BorderSide( 55 | color: Colors.transparent, 56 | width: 1, 57 | ), 58 | ), 59 | focusedBorder: OutlineInputBorder( 60 | borderSide: BorderSide( 61 | color: theme.color.heroBarFieldPlaceholder, 62 | width: 1, 63 | ), 64 | ), 65 | contentPadding: EdgeInsets.symmetric( 66 | horizontal: theme.spacing.regular, 67 | vertical: theme.spacing.small, 68 | ), 69 | ), 70 | scrollPadding: EdgeInsets.symmetric( 71 | horizontal: theme.spacing.regular, 72 | vertical: theme.spacing.small, 73 | ), 74 | autocorrect: false, 75 | obscureText: true, 76 | ), 77 | ), 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /website/lib/widgets/sliver_markdown.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:flutter_markdown/flutter_markdown.dart'; 3 | import 'package:markdown/markdown.dart' as md; 4 | 5 | typedef SliverMarkdownWidgetBuilder = Widget Function( 6 | BuildContext context, Widget child); 7 | 8 | class SliverMarkdown extends MarkdownWidget { 9 | const SliverMarkdown({ 10 | Key? key, 11 | required String data, 12 | bool selectable = false, 13 | MarkdownStyleSheet? styleSheet, 14 | MarkdownStyleSheetBaseTheme? styleSheetTheme, 15 | SyntaxHighlighter? syntaxHighlighter, 16 | MarkdownTapLinkCallback? onTapLink, 17 | VoidCallback? onTapText, 18 | String? imageDirectory, 19 | List? blockSyntaxes, 20 | List? inlineSyntaxes, 21 | md.ExtensionSet? extensionSet, 22 | MarkdownImageBuilder? imageBuilder, 23 | MarkdownCheckboxBuilder? checkboxBuilder, 24 | MarkdownBulletBuilder? bulletBuilder, 25 | Map builders = 26 | const {}, 27 | Map paddingBuilders = 28 | const {}, 29 | MarkdownListItemCrossAxisAlignment listItemCrossAxisAlignment = 30 | MarkdownListItemCrossAxisAlignment.baseline, 31 | bool fitContent = true, 32 | bool softLineBreak = false, 33 | this.padding = EdgeInsets.zero, 34 | this.itemBuilder, 35 | }) : super( 36 | key: key, 37 | data: data, 38 | selectable: selectable, 39 | styleSheet: styleSheet, 40 | styleSheetTheme: styleSheetTheme, 41 | syntaxHighlighter: syntaxHighlighter, 42 | onTapLink: onTapLink, 43 | onTapText: onTapText, 44 | imageDirectory: imageDirectory, 45 | blockSyntaxes: blockSyntaxes, 46 | inlineSyntaxes: inlineSyntaxes, 47 | extensionSet: extensionSet, 48 | imageBuilder: imageBuilder, 49 | checkboxBuilder: checkboxBuilder, 50 | builders: builders, 51 | paddingBuilders: paddingBuilders, 52 | listItemCrossAxisAlignment: listItemCrossAxisAlignment, 53 | bulletBuilder: bulletBuilder, 54 | fitContent: fitContent, 55 | softLineBreak: softLineBreak, 56 | ); 57 | 58 | final EdgeInsets padding; 59 | final SliverMarkdownWidgetBuilder? itemBuilder; 60 | 61 | @override 62 | Widget build(BuildContext context, List? children) { 63 | return SliverPadding( 64 | padding: padding, 65 | sliver: SliverList( 66 | delegate: SliverChildListDelegate([ 67 | if (children != null) 68 | ...children.map( 69 | (x) => itemBuilder?.call(context, x) ?? x, 70 | ), 71 | ]), 72 | ), 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /micropub/lib/src/server/controllers/api/api.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | import 'package:micropub/server.dart'; 4 | import 'package:micropub/src/server/auth/auth.dart'; 5 | import 'package:micropub/src/server/storage/storage.dart'; 6 | import 'package:shelf/shelf.dart' as shelf; 7 | import 'package:shelf_router/shelf_router.dart'; 8 | 9 | import 'admin.dart'; 10 | import 'packages.dart'; 11 | 12 | part 'api.g.dart'; 13 | 14 | class ApiController { 15 | const ApiController({ 16 | required this.auth, 17 | required this.storage, 18 | required this.options, 19 | }); 20 | 21 | final MicropubOptions options; 22 | final MicropubAuth auth; 23 | final MicropubStorage storage; 24 | 25 | Router get router { 26 | final result = _$ApiControllerRouter(this); 27 | 28 | final admin = shelf.Pipeline() 29 | .addMiddleware(auth.middleware) 30 | .addHandler(AdminApiController( 31 | auth: auth, 32 | ).router); 33 | 34 | final packages = shelf.Pipeline() 35 | .addMiddleware(auth.middleware) 36 | .addHandler(PackagesApiController( 37 | auth: auth, 38 | storage: storage, 39 | ).router); 40 | 41 | result.mount( 42 | '/admin', 43 | admin, 44 | ); 45 | result.mount( 46 | '/packages', 47 | packages, 48 | ); 49 | return result; 50 | } 51 | 52 | @Route.get('/me') 53 | Future me(shelf.Request req) async { 54 | return auth.middleware((req) { 55 | return MicropubMe( 56 | email: req.context['email'] as String, 57 | authorizations: 58 | req.context['authorizations'] as List, 59 | ).toJson().asJsonResponse(); 60 | })(req); 61 | } 62 | 63 | @Route.get('/info') 64 | Future info(shelf.Request req) async { 65 | return MicropubServerInfo( 66 | distantHostUrl: 67 | options.distantHostUrl ?? 'http://${options.host}:${options.port}', 68 | adminEmail: options.adminEmail, 69 | name: options.name, 70 | ).toJson().asJsonResponse(); 71 | } 72 | } 73 | 74 | extension RequestExtensions on shelf.Request { 75 | Future withAuthorizations( 76 | MicropubAuthorization authorization, 77 | Future Function() execute, 78 | ) { 79 | final authorizations = 80 | context['authorizations'] as List? ?? []; 81 | if (authorizations.contains(authorization)) return execute(); 82 | return Future.value(shelf.Response.forbidden('Unauthorized')); 83 | } 84 | } 85 | 86 | extension ResponseMapExtensions on dynamic { 87 | shelf.Response asJsonResponse() => shelf.Response.ok( 88 | json.encode(this), 89 | headers: { 90 | HttpHeaders.contentTypeHeader: ContentType.json.mimeType, 91 | 'Access-Control-Allow-Origin': '*' 92 | }, 93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /website/lib/views/packages/widgets/page_selector.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:tap_builder/tap_builder.dart'; 3 | import 'package:website/theme/theme.dart'; 4 | 5 | class PageSelector extends StatelessWidget { 6 | const PageSelector({ 7 | Key? key, 8 | required this.selected, 9 | required this.onSelectedChanged, 10 | required this.total, 11 | }) : super(key: key); 12 | 13 | final int selected; 14 | final ValueChanged onSelectedChanged; 15 | final int total; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | final theme = AppTheme.of(context); 20 | return Container( 21 | decoration: BoxDecoration( 22 | color: theme.color.tabsBackground, 23 | borderRadius: BorderRadius.all(theme.radius.regular), 24 | ), 25 | child: Wrap( 26 | alignment: WrapAlignment.center, 27 | children: [ 28 | for (var i = 0; i < total; i++) 29 | PageItem( 30 | index: i, 31 | isSelected: i == selected, 32 | onSelected: () => onSelectedChanged(i), 33 | ), 34 | ], 35 | ), 36 | ); 37 | } 38 | } 39 | 40 | class PageItem extends StatelessWidget { 41 | const PageItem({ 42 | Key? key, 43 | required this.index, 44 | required this.isSelected, 45 | required this.onSelected, 46 | }) : super(key: key); 47 | 48 | final int index; 49 | final bool isSelected; 50 | final VoidCallback onSelected; 51 | 52 | @override 53 | Widget build(BuildContext context) { 54 | final theme = AppTheme.of(context); 55 | return TapBuilder( 56 | onTap: isSelected ? null : onSelected, 57 | builder: (BuildContext context, TapState state, bool isFocused) { 58 | final foreground = () { 59 | if (isSelected) return theme.color.heroBarText1; 60 | switch (state) { 61 | case TapState.pressed: 62 | case TapState.hover: 63 | return theme.color.tabsSelectedForeground; 64 | default: 65 | return theme.color.tabsForeground; 66 | } 67 | }(); 68 | final background = () { 69 | if (isSelected) return theme.color.tabsSelectedForeground; 70 | switch (state) { 71 | case TapState.pressed: 72 | case TapState.hover: 73 | return theme.color.tabsSelectedForeground.withOpacity(0.08); 74 | default: 75 | return Colors.transparent; 76 | } 77 | }(); 78 | return Container( 79 | padding: EdgeInsets.all(theme.spacing.small), 80 | decoration: BoxDecoration( 81 | color: background, 82 | borderRadius: BorderRadius.all(theme.radius.regular), 83 | ), 84 | child: Text( 85 | (index + 1).toString(), 86 | style: theme.typography.paragraph3.copyWith( 87 | color: foreground, 88 | ), 89 | ), 90 | ); 91 | }, 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /website/lib/routing.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:website/state/notifier.dart'; 4 | import 'package:website/views/admin/admin.dart'; 5 | import 'package:website/views/packages/packages.dart'; 6 | 7 | import 'views/authentication/authentication.dart'; 8 | import 'views/package/package.dart'; 9 | 10 | typedef RoutingBuilder = Widget Function( 11 | BuildContext context, 12 | RouterDelegate routerDelegate, 13 | RouteInformationParser routeInformationParser, 14 | ); 15 | 16 | class AppRouting extends StatefulWidget { 17 | const AppRouting({ 18 | Key? key, 19 | required this.notifier, 20 | required this.builder, 21 | }) : super(key: key); 22 | 23 | final AppStateNotifier notifier; 24 | final RoutingBuilder builder; 25 | 26 | @override 27 | _AppRoutingState createState() => _AppRoutingState(); 28 | } 29 | 30 | class _AppRoutingState extends State { 31 | late final _router = GoRouter( 32 | routes: [ 33 | GoRoute( 34 | path: '/', 35 | pageBuilder: (context, state) => NoTransitionPage( 36 | key: state.pageKey, 37 | child: const PackagesView(), 38 | ), 39 | ), 40 | GoRoute( 41 | path: '/packages/:name', 42 | pageBuilder: (context, state) { 43 | final packageName = state.params['name']!; 44 | return NoTransitionPage( 45 | key: state.pageKey, 46 | child: PackageView( 47 | packageName: packageName, 48 | ), 49 | ); 50 | }, 51 | ), 52 | GoRoute( 53 | path: '/admin', 54 | pageBuilder: (context, state) => NoTransitionPage( 55 | key: state.pageKey, 56 | child: const AdminView(), 57 | ), 58 | ), 59 | GoRoute( 60 | path: '/auth', 61 | pageBuilder: (context, state) => NoTransitionPage( 62 | key: state.pageKey, 63 | child: const AuthenticationView(), 64 | ), 65 | ), 66 | ], 67 | redirect: (state) { 68 | // redirect to the login page if the user is not logged in 69 | final loggedIn = widget.notifier.value.maybeMap( 70 | authenticated: (_) => true, 71 | orElse: () => false, 72 | ); 73 | final loggingIn = state.subloc == '/auth'; 74 | 75 | if (!loggedIn) { 76 | if (loggingIn) return null; 77 | 78 | final sourceUrl = Uri.encodeQueryComponent(state.subloc); 79 | return loggingIn ? null : '/auth?from=$sourceUrl'; 80 | } 81 | if (loggingIn) { 82 | final from = state.queryParams['from']; 83 | return from != null ? Uri.decodeQueryComponent(from) : '/'; 84 | } 85 | 86 | return null; 87 | }, 88 | refreshListenable: widget.notifier, 89 | ); 90 | 91 | @override 92 | Widget build(BuildContext context) { 93 | return widget.builder( 94 | context, 95 | _router.routerDelegate, 96 | _router.routeInformationParser, 97 | ); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /micropub/lib/src/server/server.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:hive/hive.dart'; 5 | import 'package:logging/logging.dart'; 6 | import 'package:micropub/src/server/controllers/api/download.dart'; 7 | import 'package:micropub/src/server/utils/middleware_log.dart'; 8 | import 'package:shelf/shelf.dart'; 9 | import 'package:shelf/shelf_io.dart'; 10 | import 'package:shelf_cors_headers/shelf_cors_headers.dart'; 11 | import 'package:shelf_router/shelf_router.dart'; 12 | 13 | import 'auth/hive.dart'; 14 | import 'controllers/api/api.dart'; 15 | import 'controllers/static.dart'; 16 | import 'options.dart'; 17 | import 'storage/hive.dart'; 18 | import 'storage/storage.dart'; 19 | 20 | class MicropubServer { 21 | const MicropubServer({ 22 | required this.options, 23 | this.storage, 24 | }); 25 | 26 | final MicropubOptions options; 27 | final MicropubStorage? storage; 28 | 29 | Future run() async { 30 | // Initializing hive database 31 | Hive.init(options.directory); 32 | await Hive.openBox('auth.micropub'); 33 | await Hive.openBox('packages.micropub'); 34 | 35 | // Base services 36 | final auth = MicropubHiveAuth( 37 | options: options, 38 | ); 39 | final storage = this.storage ?? 40 | MicropubHiveStorage( 41 | directory: Directory(options.directory), 42 | ); 43 | 44 | // Main controllers 45 | final api = ApiController( 46 | auth: auth, 47 | storage: storage, 48 | options: options, 49 | ); 50 | final download = DownloadController( 51 | storage: storage, 52 | ); 53 | 54 | /// Authenthified controllers 55 | final apiHandler = const Pipeline() 56 | .addMiddleware(logMiddleware('API')) 57 | .addMiddleware(corsHeaders()) 58 | .addHandler(api.router); 59 | final downloadHandler = const Pipeline() 60 | .addMiddleware(logMiddleware('Download')) 61 | .addMiddleware(corsHeaders()) 62 | .addMiddleware(auth.middleware) 63 | .addHandler(download.router); 64 | 65 | // Global routing 66 | final router = Router( 67 | notFoundHandler: const StaticController().handler, 68 | ); 69 | router.mount('/api', apiHandler); 70 | router.mount('/packages', downloadHandler); 71 | 72 | var handler = const Pipeline().addMiddleware( 73 | logRequests( 74 | logger: (message, isError) { 75 | if (isError) { 76 | Logger.root.severe(message); 77 | } else { 78 | Logger.root.info(message); 79 | } 80 | }, 81 | ), 82 | ).addHandler(router); 83 | 84 | if (options.sslCert != null && options.sslKey == null) { 85 | throw Exception('An SSL certificate file has been provided without key.'); 86 | } 87 | 88 | final securityContext = options.sslCert != null 89 | ? (SecurityContext() 90 | ..useCertificateChain(options.sslCert!) 91 | ..usePrivateKey(options.sslKey!)) 92 | : null; 93 | 94 | return await serve( 95 | handler, 96 | options.host, 97 | options.port, 98 | securityContext: securityContext, 99 | ); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /website/lib/views/package/widgets/tabs.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:tap_builder/tap_builder.dart'; 3 | import 'package:website/theme/theme.dart'; 4 | 5 | class PackageTabs extends StatelessWidget { 6 | const PackageTabs({ 7 | Key? key, 8 | required this.selectedIndex, 9 | required this.onSelectedIndexChanged, 10 | required this.tabs, 11 | }) : super(key: key); 12 | 13 | final int selectedIndex; 14 | final ValueChanged onSelectedIndexChanged; 15 | final List tabs; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | final theme = AppTheme.of(context); 20 | return Container( 21 | width: double.infinity, 22 | color: theme.color.tabsBackground, 23 | padding: EdgeInsets.symmetric( 24 | horizontal: theme.spacing.regular, 25 | ), 26 | child: Wrap( 27 | spacing: theme.spacing.regular, 28 | children: [ 29 | ...tabs.asMap().entries.map( 30 | (e) => PackageTab( 31 | index: e.key, 32 | title: e.value, 33 | isSelected: e.key == selectedIndex, 34 | onSelected: () => onSelectedIndexChanged(e.key), 35 | ), 36 | ) 37 | ], 38 | ), 39 | ); 40 | } 41 | } 42 | 43 | class PackageTab extends StatelessWidget { 44 | const PackageTab({ 45 | Key? key, 46 | required this.index, 47 | required this.title, 48 | required this.isSelected, 49 | required this.onSelected, 50 | }) : super(key: key); 51 | 52 | final int index; 53 | final bool isSelected; 54 | final String title; 55 | final VoidCallback onSelected; 56 | 57 | @override 58 | Widget build(BuildContext context) { 59 | final theme = AppTheme.of(context); 60 | return TapBuilder( 61 | onTap: isSelected ? null : onSelected, 62 | builder: (BuildContext context, TapState state, bool isFocused) { 63 | final foreground = () { 64 | if (isSelected) return theme.color.tabsSelectedForeground; 65 | switch (state) { 66 | case TapState.pressed: 67 | return theme.color.tabsSelectedForeground; 68 | default: 69 | return theme.color.tabsForeground; 70 | } 71 | }(); 72 | final barColor = () { 73 | if (isSelected) return theme.color.tabsSelectedForeground; 74 | switch (state) { 75 | case TapState.pressed: 76 | case TapState.hover: 77 | return theme.color.tabsHoverForeground; 78 | default: 79 | return Colors.transparent; 80 | } 81 | }(); 82 | return Container( 83 | decoration: BoxDecoration( 84 | border: Border( 85 | bottom: BorderSide( 86 | color: barColor, 87 | width: 2, 88 | ), 89 | ), 90 | ), 91 | padding: EdgeInsets.all(theme.spacing.small), 92 | child: Text( 93 | title, 94 | style: theme.typography.paragraph1.copyWith( 95 | color: foreground, 96 | ), 97 | ), 98 | ); 99 | }, 100 | ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /micropub/README.md: -------------------------------------------------------------------------------- 1 | # micropub 2 | 3 | A minimalist private pub server for small teams. 4 | 5 | ## Setup server 6 | 7 | ### Install 8 | 9 | To install the server on your server from the `install.sh` script. 10 | 11 | ```bash 12 | curl -s https://raw.githubusercontent.com/aloisdeniel/micropub/main/install.sh | bash -s v 13 | ``` 14 | 15 | > You may need to install `unzip` tool before install. 16 | > 17 | > ```bash 18 | > sudo apt install zip 19 | > ``` 20 | 21 | ### Run the server 22 | 23 | To run the server, execute the server with arguments: 24 | 25 | * `directory`: the directory where packages and metadata are saved 26 | * `adminKey`: the root admin private key 27 | * `host`: the server host address 28 | * `port`: the server port number 29 | 30 | > You may need to modify file permissions to run the server 31 | > 32 | > ```bash 33 | > chmod +x micropub.exe 34 | > ``` 35 | 36 | #### From a config file 37 | 38 | ```bash 39 | ./micropub.exe server -c config.json 40 | ``` 41 | 42 | ```json 43 | { 44 | "directory": ".", 45 | "adminKey": "my-sensible-admin-key! # <- Replace with your own", 46 | "host": "localhost", 47 | "port": 8080 48 | } 49 | ``` 50 | 51 | #### From a environment variables 52 | 53 | ```bash 54 | export directory='.' 55 | export adminKey='my-sensible-admin-key!' # <- Replace with your own 56 | export host='localhost' 57 | export port='8080' 58 | ./micropub.exe server 59 | ``` 60 | 61 | ### Deploy 62 | 63 | You must deploy the server on a server that supports SSL since `pub` doesn't support `http` hosted references. 64 | 65 | > You can use [Letsencrypt](https://letsencrypt.org/) to get an SSL certificate. 66 | 67 | ## Usage 68 | 69 | ### Website 70 | 71 | The server provide a simple website frontend to list available packages, but also to manage user authorizations! 72 | 73 | Simply open the hosting address from your browser. 74 | 75 | ### Authentication 76 | 77 | #### pub 78 | 79 | First, make sure to authenticate your pub client by registring your access key. 80 | 81 | ```bash 82 | flutter pub token add https://mymicropubserver.com:443/ # <- your server address 83 | ``` 84 | ### Deploy a package 85 | 86 | First, make sure to add the `publish_to` property to your `pubspec.yaml` file. 87 | 88 | ```yaml 89 | name: foo 90 | description: An example package. 91 | version: 1.0.0 92 | publish_to: https://mymicropubserver.com:443/ # <- your server address 93 | ``` 94 | 95 | Then, to upload the package you have several options : using `pub` or `micropub`. 96 | 97 | 98 | #### Micropub 99 | 100 | The micropub allows you to upload the package. 101 | 102 | It may be usefull if you don't want to install the full Dart environment. Be aware that compared to pub regular uploads, there's no package verification before upload. 103 | 104 | ```bash 105 | export MICROPUB_ACCESS_KEY=my-sensible-admin-key! # <- Replace with your own 106 | ./micropub.exe publish 107 | ``` 108 | 109 | #### pub 110 | 111 | Make sure that your `pub` client is authenticated. 112 | 113 | ```bash 114 | ./micropub.exe publish 115 | ``` 116 | 117 | ### Reference a package 118 | 119 | Make sure that your `pub` client is authenticated and add the `hosted` dependency. 120 | 121 | ```bash 122 | dependencies: 123 | foo: 124 | hosted: https://mymicropubserver.com:443/ # <- your server address 125 | version: ^0.0.1 126 | ``` 127 | 128 | ## Thanks 129 | 130 | * [unpub](https://pub.dev/packages/unpub) on which is based this package. 131 | -------------------------------------------------------------------------------- /website/lib/views/packages/widgets/package_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:timeago/timeago.dart' as timeago; 3 | import 'package:gap/gap.dart'; 4 | import 'package:go_router/go_router.dart'; 5 | import 'package:tap_builder/tap_builder.dart'; 6 | import 'package:micropub/client.dart'; 7 | import 'package:website/theme/theme.dart'; 8 | 9 | class PackageTile extends StatelessWidget { 10 | const PackageTile({ 11 | Key? key, 12 | required this.package, 13 | }) : super(key: key); 14 | 15 | final MicropubPackage package; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | final theme = AppTheme.of(context); 20 | final version = package.versions.first; 21 | final description = version.pubspec['description'] as String?; 22 | final updatedAt = package.updatedAt; 23 | return DecoratedBox( 24 | decoration: BoxDecoration( 25 | color: theme.color.bodyBackground, 26 | ), 27 | child: Center( 28 | child: TapBuilder( 29 | onTap: () { 30 | context.go('/packages/${Uri.encodeComponent(package.name)}'); 31 | }, 32 | builder: (context, state, isFocused) { 33 | return ConstrainedBox( 34 | constraints: BoxConstraints(maxWidth: theme.size.maxWidth), 35 | child: Container( 36 | color: () { 37 | switch (state) { 38 | case TapState.hover: 39 | return theme.color.bodyHoverBackground; 40 | default: 41 | return Colors.transparent; 42 | } 43 | }(), 44 | padding: EdgeInsets.all(theme.spacing.big), 45 | child: Column( 46 | mainAxisSize: MainAxisSize.min, 47 | crossAxisAlignment: CrossAxisAlignment.stretch, 48 | children: [ 49 | Text( 50 | package.name, 51 | style: theme.typography.title3.copyWith( 52 | color: theme.color.bodyAccentText1, 53 | ), 54 | ), 55 | if (description != null) ...[ 56 | Gap(theme.spacing.small), 57 | Text( 58 | description, 59 | style: theme.typography.paragraph2, 60 | ), 61 | ], 62 | Gap(theme.spacing.small), 63 | Row( 64 | mainAxisSize: MainAxisSize.min, 65 | children: [ 66 | Text( 67 | 'v', 68 | style: theme.typography.paragraph3, 69 | ), 70 | Text( 71 | version.version, 72 | style: theme.typography.paragraph3.copyWith( 73 | color: theme.color.bodyAccentText1, 74 | ), 75 | ), 76 | if (updatedAt != null) ...[ 77 | Gap(theme.spacing.small), 78 | Text( 79 | '(${timeago.format(updatedAt)})', 80 | style: theme.typography.paragraph3.copyWith( 81 | color: theme.color.heroBarFieldPlaceholder, 82 | ), 83 | ), 84 | ] 85 | ], 86 | ), 87 | ], 88 | ), 89 | ), 90 | ); 91 | }, 92 | ), 93 | ), 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "v*.*.*" 6 | jobs: 7 | build-website: 8 | name: Build website 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - uses: subosito/flutter-action@v1 13 | - name: Build 14 | run: | 15 | cd ./website 16 | flutter pub get 17 | flutter build web 18 | cd ./scripts 19 | dart bundler.dart 20 | - uses: papeloto/action-zip@v1 21 | with: 22 | files: micropub/bin/static/ 23 | dest: static.zip 24 | - uses: actions/upload-artifact@master 25 | with: 26 | name: static 27 | path: static.zip 28 | 29 | build-server-ubuntu: 30 | needs: build-website 31 | name: Build server (Ubuntu) 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v1 35 | - uses: dart-lang/setup-dart@v1 36 | - name: Build 37 | run: | 38 | cd ./micropub 39 | dart pub get 40 | dart compile exe bin/micropub.dart 41 | - uses: actions/upload-artifact@master 42 | with: 43 | name: micropub-ubuntu 44 | path: micropub/bin/micropub.exe 45 | 46 | build-server-macos: 47 | needs: build-website 48 | name: Build server (macOS) 49 | runs-on: macos-latest 50 | steps: 51 | - uses: actions/checkout@v1 52 | - uses: dart-lang/setup-dart@v1 53 | - name: Build 54 | run: | 55 | cd ./micropub 56 | dart pub get 57 | dart compile exe bin/micropub.dart 58 | - uses: actions/upload-artifact@master 59 | with: 60 | name: micropub-macos 61 | path: micropub/bin/micropub.exe 62 | 63 | build-server-windows: 64 | needs: build-website 65 | name: Build server (Windows) 66 | runs-on: windows-latest 67 | steps: 68 | - uses: actions/checkout@v1 69 | - uses: dart-lang/setup-dart@v1 70 | - name: Build 71 | run: | 72 | cd ./micropub 73 | dart pub get 74 | dart compile exe bin/micropub.dart 75 | - uses: actions/upload-artifact@master 76 | with: 77 | name: micropub-windows 78 | path: micropub/bin/micropub.exe 79 | 80 | publish: 81 | needs: [build-server-ubuntu,build-server-macos,build-server-windows] 82 | name: Publish 83 | runs-on: ubuntu-latest 84 | steps: 85 | - uses: actions/download-artifact@master 86 | id: download 87 | with: 88 | path: artifacts 89 | - name: List artifact files 90 | run: ls -R 91 | - name: Renamming files 92 | run: | 93 | mv ${{steps.download.outputs.download-path}}/micropub-macos/micropub.exe ${{steps.download.outputs.download-path}}/micropub-macos/micropub-macos.exe 94 | mv ${{steps.download.outputs.download-path}}/micropub-windows/micropub.exe ${{steps.download.outputs.download-path}}/micropub-windows/micropub-windows.exe 95 | mv ${{steps.download.outputs.download-path}}/micropub-ubuntu/micropub.exe ${{steps.download.outputs.download-path}}/micropub-ubuntu/micropub-ubuntu.exe 96 | 97 | - name: Release 98 | uses: softprops/action-gh-release@v1 99 | with: 100 | files: | 101 | ${{steps.download.outputs.download-path}}/micropub-macos/micropub-macos.exe 102 | ${{steps.download.outputs.download-path}}/micropub-windows/micropub-windows.exe 103 | ${{steps.download.outputs.download-path}}/micropub-ubuntu/micropub-ubuntu.exe 104 | ${{steps.download.outputs.download-path}}/static/static.zip 105 | 106 | -------------------------------------------------------------------------------- /website/lib/state/state.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | import 'package:micropub/client.dart'; 3 | 4 | part 'state.freezed.dart'; 5 | 6 | @Freezed() 7 | class AppState with _$AppState { 8 | const factory AppState.initializing({ 9 | required MicropubApiClient client, 10 | }) = AppStateInitializing; 11 | const factory AppState.initializationFailed({ 12 | required MicropubApiClient client, 13 | required dynamic error, 14 | }) = AppStateInitializationFailed; 15 | const factory AppState.initialized({ 16 | required MicropubServerInfo info, 17 | required MicropubApiClient client, 18 | }) = AppStateInitialized; 19 | const factory AppState.notAuthenticated({ 20 | required MicropubServerInfo info, 21 | required MicropubApiClient client, 22 | }) = AppStateNotAuthenticated; 23 | const factory AppState.authenticationFailed({ 24 | required MicropubServerInfo info, 25 | required MicropubApiClient client, 26 | }) = AppStateNotAuthenticationFailed; 27 | const factory AppState.authenticating({ 28 | required MicropubServerInfo info, 29 | required MicropubApiClient client, 30 | }) = AppStateAuthenticating; 31 | const factory AppState.authenticated({ 32 | required MicropubMe me, 33 | required MicropubServerInfo info, 34 | required MicropubApiAuthenticatedClient client, 35 | required PackagesState packages, 36 | required AdminState admin, 37 | required PackageState package, 38 | }) = AppStateAuthenticated; 39 | } 40 | 41 | @Freezed() 42 | class PackagesState with _$PackagesState { 43 | const factory PackagesState.notLoaded({ 44 | required int page, 45 | required int pageSize, 46 | required String query, 47 | }) = PackagesNotLoaded; 48 | const factory PackagesState.loading({ 49 | required int page, 50 | required int pageSize, 51 | required String query, 52 | }) = PackagesLoading; 53 | const factory PackagesState.failed({ 54 | required int page, 55 | required int pageSize, 56 | required String query, 57 | required dynamic error, 58 | required StackTrace? stackTrace, 59 | }) = PackagesFailed; 60 | const factory PackagesState.empty({ 61 | required int page, 62 | required int pageSize, 63 | required String query, 64 | }) = PackagesEmpty; 65 | const factory PackagesState.result({ 66 | required int page, 67 | required int pageSize, 68 | required String query, 69 | required int totalPages, 70 | required MicropubQueryResult result, 71 | }) = PackagesResult; 72 | } 73 | 74 | @Freezed() 75 | class PackageState with _$PackageState { 76 | const factory PackageState.notLoaded() = PackageStateNotLoaded; 77 | const factory PackageState.loading({ 78 | required String packageName, 79 | }) = PackageStateLoading; 80 | const factory PackageState.failed({ 81 | required String packageName, 82 | required dynamic error, 83 | required StackTrace? stackTrace, 84 | }) = PackageStateFailed; 85 | const factory PackageState.result({ 86 | required String packageName, 87 | required MicropubPackageDetails result, 88 | }) = PackageStateResult; 89 | } 90 | 91 | @Freezed() 92 | class AdminState with _$AdminState { 93 | const factory AdminState.notAuthorized() = AdminStateNotAuthorized; 94 | const factory AdminState.notLoaded() = AdminStateNotLoaded; 95 | const factory AdminState.loading() = AdminStateLoading; 96 | const factory AdminState.failed({ 97 | required dynamic error, 98 | required StackTrace? stackTrace, 99 | }) = AdminFailed; 100 | const factory AdminState.empty() = AdminEmpty; 101 | const factory AdminState.result({ 102 | required List accessKeys, 103 | }) = AdminResult; 104 | } 105 | -------------------------------------------------------------------------------- /website/lib/views/packages/widgets/search_bar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:website/state/notifier.dart'; 4 | import 'package:website/theme/theme.dart'; 5 | 6 | class PackagesSearchBar extends StatelessWidget { 7 | const PackagesSearchBar({ 8 | Key? key, 9 | required this.initialQuery, 10 | }) : super(key: key); 11 | 12 | final String initialQuery; 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final theme = AppTheme.of(context); 17 | return DefaultTextStyle( 18 | style: theme.typography.title3.copyWith( 19 | color: theme.color.heroBarText1, 20 | ), 21 | child: Container( 22 | color: theme.color.heroBarBackground, 23 | padding: EdgeInsets.all(theme.spacing.big), 24 | child: Center( 25 | child: ConstrainedBox( 26 | constraints: BoxConstraints(maxWidth: theme.size.maxWidth), 27 | child: _SearchField( 28 | initial: initialQuery, 29 | onValidate: (v) { 30 | context.read().loadPackages(0, v); 31 | }, 32 | ), 33 | ), 34 | ), 35 | ), 36 | ); 37 | } 38 | } 39 | 40 | class _SearchField extends StatefulWidget { 41 | const _SearchField({ 42 | Key? key, 43 | required this.initial, 44 | required this.onValidate, 45 | }) : super(key: key); 46 | 47 | final String initial; 48 | final ValueChanged onValidate; 49 | 50 | @override 51 | State<_SearchField> createState() => _SearchFieldState(); 52 | } 53 | 54 | class _SearchFieldState extends State<_SearchField> { 55 | late final controller = TextEditingController( 56 | text: widget.initial, 57 | ); 58 | 59 | @override 60 | void dispose() { 61 | controller.dispose(); 62 | super.dispose(); 63 | } 64 | 65 | @override 66 | Widget build(BuildContext context) { 67 | final theme = AppTheme.of(context); 68 | return Container( 69 | decoration: BoxDecoration( 70 | color: theme.color.heroBarFieldBackground, 71 | borderRadius: const BorderRadius.all( 72 | Radius.circular(300), 73 | ), 74 | ), 75 | child: Material( 76 | color: Colors.transparent, 77 | child: TextField( 78 | onSubmitted: (v) { 79 | widget.onValidate(v); 80 | }, 81 | controller: controller, 82 | style: theme.typography.paragraph1.copyWith( 83 | color: theme.color.heroBarFieldText1, 84 | ), 85 | decoration: InputDecoration( 86 | hintText: 'Search packages', 87 | hintStyle: theme.typography.paragraph1.copyWith( 88 | color: theme.color.heroBarFieldPlaceholder, 89 | ), 90 | border: const OutlineInputBorder( 91 | borderRadius: BorderRadius.all( 92 | Radius.circular(300), 93 | ), 94 | borderSide: BorderSide( 95 | color: Colors.transparent, 96 | width: 1, 97 | ), 98 | ), 99 | focusedBorder: OutlineInputBorder( 100 | borderRadius: const BorderRadius.all( 101 | Radius.circular(300), 102 | ), 103 | borderSide: BorderSide( 104 | color: theme.color.heroBarFieldPlaceholder, 105 | width: 1, 106 | ), 107 | ), 108 | contentPadding: EdgeInsets.symmetric( 109 | horizontal: theme.spacing.regular, 110 | ), 111 | ), 112 | autocorrect: false, 113 | ), 114 | ), 115 | ); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /website/lib/theme/data/colors.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | class ColorData extends Equatable { 5 | const ColorData({ 6 | required this.heroBarBackground, 7 | required this.heroBarText1, 8 | required this.bodyAccentText1, 9 | required this.bodyBackground, 10 | required this.heroBarFieldBackground, 11 | required this.bodyText1, 12 | required this.heroBarFieldPlaceholder, 13 | required this.heroBarFieldText1, 14 | required this.heroBarText2, 15 | required this.primaryButtonBackground, 16 | required this.primaryButtonForeground, 17 | required this.destructiveButtonBackground, 18 | required this.destructiveButtonForeground, 19 | required this.barBarBackground, 20 | required this.barBarText1, 21 | required this.bodyHoverBackground, 22 | required this.tabsBackground, 23 | required this.tabsForeground, 24 | required this.tabsHoverForeground, 25 | required this.tabsSelectedForeground, 26 | required this.adminAccent, 27 | required this.readAccent, 28 | required this.writeAccent, 29 | }); 30 | 31 | const ColorData.light() 32 | : heroBarBackground = const Color(0xFF132030), 33 | heroBarText1 = const Color(0xFFFFFFFF), 34 | heroBarText2 = const Color(0xFF7C8087), 35 | barBarBackground = const Color(0xFF1c2834), 36 | barBarText1 = const Color(0xFFFFFFFF), 37 | bodyBackground = const Color(0xFFFFFFFF), 38 | bodyHoverBackground = const Color(0xFFF9F9F9), 39 | bodyText1 = const Color(0xFF4a4a4a), 40 | heroBarFieldBackground = const Color(0xFF35404d), 41 | heroBarFieldText1 = const Color(0xFFFFFFFF), 42 | heroBarFieldPlaceholder = const Color(0xFF767778), 43 | bodyAccentText1 = const Color(0xFF0175c2), 44 | primaryButtonBackground = const Color(0xFF31b0fc), 45 | primaryButtonForeground = const Color(0xFF132030), 46 | destructiveButtonBackground = const Color(0xFFE9343F), 47 | destructiveButtonForeground = const Color(0xFF501014), 48 | tabsBackground = const Color(0xFFf5f5f7), 49 | tabsForeground = const Color(0xFF4a4a4a), 50 | tabsHoverForeground = const Color(0xFFDFDEDD), 51 | tabsSelectedForeground = const Color(0xFF1967d2), 52 | readAccent = const Color(0xff1967d2), 53 | writeAccent = const Color(0xffffa500), 54 | adminAccent = const Color.fromARGB(255, 136, 68, 196); 55 | 56 | final Color barBarBackground; 57 | final Color barBarText1; 58 | 59 | final Color tabsBackground; 60 | final Color tabsForeground; 61 | final Color tabsHoverForeground; 62 | final Color tabsSelectedForeground; 63 | 64 | final Color heroBarBackground; 65 | final Color heroBarText1; 66 | final Color heroBarText2; 67 | final Color heroBarFieldBackground; 68 | final Color heroBarFieldText1; 69 | final Color heroBarFieldPlaceholder; 70 | 71 | final Color primaryButtonBackground; 72 | final Color primaryButtonForeground; 73 | final Color destructiveButtonBackground; 74 | final Color destructiveButtonForeground; 75 | 76 | final Color readAccent; 77 | final Color writeAccent; 78 | final Color adminAccent; 79 | 80 | final Color bodyBackground; 81 | final Color bodyHoverBackground; 82 | // Paragraphs 83 | final Color bodyText1; 84 | // Package title, links 85 | final Color bodyAccentText1; 86 | 87 | @override 88 | List get props => [ 89 | heroBarBackground, 90 | heroBarText1, 91 | bodyBackground, 92 | bodyText1, 93 | bodyAccentText1, 94 | heroBarFieldBackground, 95 | heroBarFieldPlaceholder, 96 | heroBarFieldText1, 97 | primaryButtonBackground, 98 | primaryButtonForeground, 99 | tabsBackground, 100 | ]; 101 | } 102 | -------------------------------------------------------------------------------- /micropub/lib/src/shared/model.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: invalid_annotation_target 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | part 'model.freezed.dart'; 6 | part 'model.g.dart'; 7 | 8 | dynamic jsonFromDateTime(DateTime? x) => x?.millisecondsSinceEpoch; 9 | DateTime? jsonToDateTime(dynamic x) => 10 | x is int ? DateTime.fromMillisecondsSinceEpoch(x) : null; 11 | 12 | const jsonDateTime = 13 | JsonKey(fromJson: jsonToDateTime, toJson: jsonFromDateTime); 14 | 15 | @Freezed() 16 | class MicropubVersion with _$MicropubVersion { 17 | @JsonSerializable(explicitToJson: true, anyMap: true) 18 | const factory MicropubVersion({ 19 | required String version, 20 | required Map pubspec, 21 | String? pubspecYaml, 22 | String? uploader, 23 | String? readme, 24 | String? changelog, 25 | @jsonDateTime DateTime? createdAt, 26 | }) = _MicropubVersion; 27 | 28 | factory MicropubVersion.fromJson(Map map) => 29 | _$MicropubVersionFromJson(map); 30 | } 31 | 32 | @Freezed() 33 | class MicropubPackage with _$MicropubPackage { 34 | @JsonSerializable(explicitToJson: true, anyMap: true) 35 | const factory MicropubPackage({ 36 | required String name, 37 | required List versions, 38 | List? uploaders, 39 | int? download, 40 | @jsonDateTime DateTime? createdAt, 41 | @jsonDateTime DateTime? updatedAt, 42 | }) = _MicropubPackage; 43 | 44 | factory MicropubPackage.fromJson(Map map) => 45 | _$MicropubPackageFromJson(map); 46 | } 47 | 48 | @Freezed() 49 | class MicropubPackageDetails with _$MicropubPackageDetails { 50 | @JsonSerializable(explicitToJson: true, anyMap: true) 51 | const factory MicropubPackageDetails({ 52 | required MicropubPackage package, 53 | }) = _MicropubPackageDetails; 54 | 55 | factory MicropubPackageDetails.fromJson(Map map) => 56 | _$MicropubPackageDetailsFromJson(map); 57 | } 58 | 59 | @Freezed() 60 | class MicropubMe with _$MicropubMe { 61 | @JsonSerializable(explicitToJson: true, anyMap: true) 62 | const factory MicropubMe({ 63 | required String email, 64 | required List authorizations, 65 | }) = _MicropubMe; 66 | 67 | factory MicropubMe.fromJson(Map map) => 68 | _$MicropubMeFromJson(map); 69 | } 70 | 71 | @Freezed() 72 | class MicropubQueryResult with _$MicropubQueryResult { 73 | @JsonSerializable(explicitToJson: true, anyMap: true) 74 | const factory MicropubQueryResult({ 75 | required int count, 76 | required List packages, 77 | }) = _MicropubQueryResult; 78 | 79 | factory MicropubQueryResult.fromJson(Map map) => 80 | _$MicropubQueryResultFromJson(map); 81 | } 82 | 83 | @Freezed() 84 | class MicropubAccessKey with _$MicropubAccessKey { 85 | @JsonSerializable(explicitToJson: true, anyMap: true) 86 | const factory MicropubAccessKey({ 87 | required String id, 88 | required String key, 89 | required String email, 90 | required DateTime creationDate, 91 | required List authorizations, 92 | }) = _MicropubAccessKey; 93 | 94 | factory MicropubAccessKey.fromJson(Map map) => 95 | _$MicropubAccessKeyFromJson(map.cast()); 96 | } 97 | 98 | @Freezed() 99 | class MicropubServerInfo with _$MicropubServerInfo { 100 | @JsonSerializable(explicitToJson: true, anyMap: true) 101 | const factory MicropubServerInfo({ 102 | String? name, 103 | required String adminEmail, 104 | required String distantHostUrl, 105 | }) = _MicropubServerInfo; 106 | 107 | factory MicropubServerInfo.fromJson(Map map) => 108 | _$MicropubServerInfoFromJson(map); 109 | } 110 | 111 | enum MicropubAuthorization { 112 | // Can create access key 113 | admin, 114 | // Can read packages 115 | read, 116 | // Can update packages and versions 117 | write, 118 | } 119 | 120 | MicropubAuthorization micropubAuthorizationFromString(String value) => 121 | _$MicropubAuthorizationEnumMap.entries 122 | .firstWhere((x) => x.value == value) 123 | .key; 124 | -------------------------------------------------------------------------------- /website/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | website 33 | 34 | 35 | 36 | 39 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /website/lib/views/admin/widgets/create_key_dialog.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:provider/provider.dart'; 5 | import 'package:micropub/client.dart'; 6 | import 'package:website/state/notifier.dart'; 7 | 8 | class CreateAccessKeyDialog extends StatefulWidget { 9 | const CreateAccessKeyDialog({ 10 | Key? key, 11 | }) : super(key: key); 12 | 13 | static Future show(BuildContext context) { 14 | return showDialog( 15 | context: context, 16 | builder: (context) { 17 | return const CreateAccessKeyDialog(); 18 | }, 19 | ); 20 | } 21 | 22 | @override 23 | State createState() => _CreateAccessKeyDialogState(); 24 | } 25 | 26 | class _CreateAccessKeyDialogState extends State { 27 | final TextEditingController email = TextEditingController(); 28 | var isLoading = false; 29 | var read = true; 30 | var write = true; 31 | var admin = false; 32 | MicropubAccessKey? createdKey; 33 | 34 | @override 35 | Widget build(BuildContext context) { 36 | if (createdKey != null) { 37 | return AlertDialog( 38 | title: const Text('Success'), 39 | content: SelectableText(createdKey!.key), 40 | actions: [ 41 | TextButton( 42 | child: const Text('Close'), 43 | onPressed: () => Navigator.pop(context), 44 | ), 45 | ], 46 | ); 47 | } 48 | 49 | return AlertDialog( 50 | title: const Text('Create a new access key'), 51 | content: Column( 52 | crossAxisAlignment: CrossAxisAlignment.stretch, 53 | mainAxisSize: MainAxisSize.min, 54 | children: [ 55 | TextField( 56 | controller: email, 57 | decoration: const InputDecoration(hintText: "User's email"), 58 | ), 59 | CheckboxListTile( 60 | title: const Text('Download'), 61 | value: read, 62 | onChanged: (bool? value) { 63 | setState(() { 64 | read = !read; 65 | }); 66 | }, 67 | ), 68 | CheckboxListTile( 69 | title: const Text('Publish'), 70 | value: write, 71 | onChanged: (bool? value) { 72 | setState(() { 73 | write = !write; 74 | }); 75 | }, 76 | ), 77 | CheckboxListTile( 78 | title: const Text('Admin'), 79 | value: admin, 80 | onChanged: (bool? value) { 81 | setState(() { 82 | admin = !admin; 83 | }); 84 | }, 85 | ), 86 | ], 87 | ), 88 | actions: [ 89 | TextButton( 90 | child: const Text('Cancel'), 91 | onPressed: () => Navigator.pop(context), 92 | ), 93 | AnimatedBuilder( 94 | animation: email, 95 | builder: (context, _) { 96 | return TextButton( 97 | child: const Text('Create'), 98 | onPressed: 99 | email.text.trim().isEmpty || (!write && !read && !admin) 100 | ? null 101 | : () async { 102 | final notifier = context.read(); 103 | setState(() { 104 | isLoading = true; 105 | }); 106 | try { 107 | final newKey = await notifier.createAccessKey( 108 | email.text.trim(), 109 | read, 110 | write, 111 | admin, 112 | ); 113 | setState(() { 114 | createdKey = newKey; 115 | }); 116 | } finally { 117 | setState(() { 118 | isLoading = false; 119 | }); 120 | } 121 | unawaited(notifier.refreshAdmin()); 122 | }, 123 | ); 124 | }), 125 | ], 126 | ); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /website/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: website 2 | description: A new Flutter project. 3 | 4 | # The following line prevents the package from being accidentally published to 5 | # pub.dev using `flutter pub publish`. This is preferred for private packages. 6 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev 7 | 8 | # The following defines the version and build number for your application. 9 | # A version number is three numbers separated by dots, like 1.2.43 10 | # followed by an optional build number separated by a +. 11 | # Both the version and the builder number may be overridden in flutter 12 | # build by specifying --build-name and --build-number, respectively. 13 | # In Android, build-name is used as versionName while build-number used as versionCode. 14 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 15 | # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. 16 | # Read more about iOS versioning at 17 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 18 | version: 1.0.0+1 19 | 20 | environment: 21 | sdk: ">=2.16.1 <3.0.0" 22 | 23 | # Dependencies specify other packages that your package needs in order to work. 24 | # To automatically upgrade your package dependencies to the latest versions 25 | # consider running `flutter pub upgrade --major-versions`. Alternatively, 26 | # dependencies can be manually updated by changing the version numbers below to 27 | # the latest version available on pub.dev. To see which dependencies have newer 28 | # versions available, run `flutter pub outdated`. 29 | dependencies: 30 | flutter: 31 | sdk: flutter 32 | shared_preferences: ^2.0.13 33 | equatable: ^2.0.3 34 | freezed_annotation: ^1.1.0 35 | http: ^0.13.4 36 | provider: ^6.0.2 37 | go_router: ^3.0.4 38 | gap: ^2.0.0 39 | tap_builder: ^0.3.4 40 | flutter_markdown: ^0.6.9 41 | timeago: ^3.2.2 42 | micropub: 43 | path: ../micropub 44 | url_launcher: ^6.0.20 45 | sliver_tools: ^0.2.5 46 | 47 | dev_dependencies: 48 | flutter_test: 49 | sdk: flutter 50 | 51 | # The "flutter_lints" package below contains a set of recommended lints to 52 | # encourage good coding practices. The lint set provided by the package is 53 | # activated in the `analysis_options.yaml` file located at the root of your 54 | # package. See that file for information about deactivating specific lint 55 | # rules and activating additional ones. 56 | flutter_lints: ^1.0.0 57 | freezed: ^1.1.1 58 | build_runner: ^2.1.7 59 | json_serializable: ^6.1.5 60 | 61 | # For information on the generic Dart part of this file, see the 62 | # following page: https://dart.dev/tools/pub/pubspec 63 | 64 | # The following section is specific to Flutter. 65 | flutter: 66 | 67 | # The following line ensures that the Material Icons font is 68 | # included with your application, so that you can use the icons in 69 | # the material Icons class. 70 | uses-material-design: true 71 | 72 | # To add assets to your application, add an assets section, like this: 73 | # assets: 74 | # - images/a_dot_burr.jpeg 75 | # - images/a_dot_ham.jpeg 76 | 77 | # An image asset can refer to one or more resolution-specific "variants", see 78 | # https://flutter.dev/assets-and-images/#resolution-aware. 79 | 80 | # For details regarding adding assets from package dependencies, see 81 | # https://flutter.dev/assets-and-images/#from-packages 82 | 83 | # To add custom fonts to your application, add a fonts section here, 84 | # in this "flutter" section. Each entry in this list should have a 85 | # "family" key with the font family name, and a "fonts" key with a 86 | # list giving the asset and other descriptors for the font. For 87 | # example: 88 | fonts: 89 | - family: Roboto 90 | fonts: 91 | - asset: fonts/Roboto-Light.ttf 92 | weight: 300 93 | - asset: fonts/Roboto-Regular.ttf 94 | weight: 400 95 | - asset: fonts/Roboto-Bold.ttf 96 | weight: 700 97 | # fonts: 98 | # - family: Schyler 99 | # fonts: 100 | # - asset: fonts/Schyler-Regular.ttf 101 | # - asset: fonts/Schyler-Italic.ttf 102 | # style: italic 103 | # - family: Trajan Pro 104 | # fonts: 105 | # - asset: fonts/TrajanPro.ttf 106 | # - asset: fonts/TrajanPro_Bold.ttf 107 | # weight: 700 108 | # 109 | # For details regarding fonts from package dependencies, 110 | # see https://flutter.dev/custom-fonts/#from-packages 111 | -------------------------------------------------------------------------------- /micropub/bin/micropub.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:archive/archive_io.dart'; 4 | import 'package:args/command_runner.dart'; 5 | import 'package:logging/logging.dart'; 6 | import 'package:micropub/client.dart'; 7 | import 'package:micropub/server.dart'; 8 | import 'package:micropub/src/server/storage/fake.dart'; 9 | import 'package:micropub/src/server/utils/yaml.dart'; 10 | import 'package:path/path.dart'; 11 | 12 | void main(List args) async { 13 | Logger.root.level = Level.INFO; 14 | Logger.root.onRecord.listen((record) { 15 | print('(${record.level.name}) ${record.message}'); 16 | }); 17 | 18 | final runner = CommandRunner( 19 | "micropub", "A minimalist private pub server for small teams.") 20 | ..addCommand(ServerCommand()) 21 | ..addCommand(PublishCommand()); 22 | 23 | await runner.run(args); 24 | } 25 | 26 | class ServerCommand extends Command { 27 | @override 28 | String get name => "server"; 29 | 30 | @override 31 | String get description => "Start a micropub server."; 32 | 33 | ServerCommand() { 34 | argParser.addOption('config', abbr: 'c'); 35 | argParser.addFlag('fake', abbr: 'f', defaultsTo: false); 36 | } 37 | 38 | @override 39 | Future run() async { 40 | final results = argResults; 41 | if (results != null) { 42 | final config = results['config'] as String?; 43 | final fake = results['fake'] as bool; 44 | 45 | if (fake) print('Faked mode'); 46 | 47 | final options = await _loadOptions(config); 48 | 49 | var app = MicropubServer( 50 | options: options, 51 | storage: fake ? MicropubFakeStorage() : null, 52 | ); 53 | final server = await app.run(); 54 | print( 55 | '[Micropub] Serving at https://${server.address.host}:${server.port}'); 56 | } 57 | } 58 | 59 | Future _loadOptions(String? config) async { 60 | if (config != null) { 61 | final file = File(config); 62 | return await MicropubOptions.fromFile(file); 63 | } 64 | return MicropubOptions.fromEnv(); 65 | } 66 | } 67 | 68 | class PublishCommand extends Command { 69 | @override 70 | String get name => "publish"; 71 | 72 | @override 73 | String get description => 74 | 'Used to remove the need for dart and pub for publishing packages' 75 | '(for example for CI).' 76 | 'This also removes the need for SSL and bypass package verifications'; 77 | 78 | PublishCommand() { 79 | argParser.addOption('key', abbr: 'k'); 80 | } 81 | 82 | @override 83 | Future run() async { 84 | final results = argResults; 85 | if (results != null) { 86 | final keyArg = results['key'] as String?; 87 | 88 | if (keyArg == null && 89 | !Platform.environment.containsKey('MICROPUB_ACCESS_KEY')) { 90 | throw Exception('No access key'); 91 | } 92 | 93 | final key = keyArg ?? Platform.environment['MICROPUB_ACCESS_KEY']!; 94 | 95 | final location = 96 | Directory(results.rest.isNotEmpty ? results.rest.first : '.'); 97 | 98 | final pubspecFile = File(join(location.path, 'pubspec.yaml')); 99 | if (!pubspecFile.existsSync()) throw Exception('No pubspec.yaml file'); 100 | 101 | final pubspec = loadYamlAsMap(await pubspecFile.readAsString())!; 102 | 103 | if (!pubspec.containsKey('publish_to')) { 104 | throw Exception( 105 | 'A `publish_to` micropub host server address must be provided in pubspec file'); 106 | } 107 | 108 | final host = pubspec['publish_to'] as String; 109 | final name = pubspec['name'] as String; 110 | final version = pubspec['version'] as String; 111 | 112 | final archive = Archive(); 113 | 114 | await for (var item in location.list(recursive: true)) { 115 | if (item is File) { 116 | var relativePath = item.path.replaceFirst(location.path, ''); 117 | if (relativePath.startsWith('/')) { 118 | relativePath = relativePath.substring(1); 119 | } 120 | if (![ 121 | '.dart_tool/', 122 | '.packages', 123 | '.pubspec.lock', 124 | ].any((x) => relativePath.startsWith(x))) { 125 | final content = await item.readAsBytes(); 126 | final archiveFile = ArchiveFile( 127 | relativePath, 128 | content.lengthInBytes, 129 | content, 130 | ); 131 | archive.addFile(archiveFile); 132 | } 133 | } 134 | } 135 | 136 | final tarData = TarEncoder().encode(archive); 137 | 138 | final client = MicropubApiAuthenticatedClient( 139 | accessKey: key, 140 | baseUri: host, 141 | ); 142 | 143 | await client.uploadPackage( 144 | name: name, 145 | version: version, 146 | archive: tarData, 147 | ); 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /website/lib/views/admin/widgets/access_key_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:gap/gap.dart'; 3 | import 'package:tap_builder/tap_builder.dart'; 4 | import 'package:micropub/client.dart'; 5 | import 'package:website/theme/theme.dart'; 6 | import 'package:website/views/admin/widgets/revoke_key_dialog.dart'; 7 | import 'package:website/widgets/flat_button.dart'; 8 | import 'package:timeago/timeago.dart' as timeago; 9 | 10 | class AccessKeyTile extends StatelessWidget { 11 | const AccessKeyTile({ 12 | Key? key, 13 | required this.accessKey, 14 | }) : super(key: key); 15 | 16 | final MicropubAccessKey accessKey; 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | final theme = AppTheme.of(context); 21 | return DecoratedBox( 22 | decoration: BoxDecoration( 23 | color: theme.color.bodyBackground, 24 | ), 25 | child: Center( 26 | child: ConstrainedBox( 27 | constraints: BoxConstraints(maxWidth: theme.size.maxWidth), 28 | child: Padding( 29 | padding: EdgeInsets.all(theme.spacing.big), 30 | child: Row( 31 | crossAxisAlignment: CrossAxisAlignment.start, 32 | children: [ 33 | Expanded( 34 | child: Column( 35 | mainAxisSize: MainAxisSize.min, 36 | crossAxisAlignment: CrossAxisAlignment.stretch, 37 | children: [ 38 | Text( 39 | accessKey.key, 40 | style: theme.typography.title3, 41 | ), 42 | Gap(theme.spacing.small), 43 | Text( 44 | accessKey.email, 45 | style: theme.typography.paragraph3, 46 | ), 47 | Gap(theme.spacing.small), 48 | Row( 49 | mainAxisSize: MainAxisSize.min, 50 | children: [ 51 | ...accessKey.authorizations.map( 52 | (e) => AuthorizationLabel(e), 53 | ), 54 | ], 55 | ), 56 | Gap(theme.spacing.small), 57 | Text( 58 | timeago.format(accessKey.creationDate), 59 | style: theme.typography.paragraph3.copyWith( 60 | color: theme.color.heroBarFieldPlaceholder, 61 | ), 62 | ), 63 | ], 64 | ), 65 | ), 66 | PopupMenuButton( 67 | onSelected: (x) { 68 | if (x == 'Revoke') { 69 | RevokeKeyDialog.show(context, accessKey); 70 | } 71 | }, 72 | itemBuilder: (BuildContext context) { 73 | return {'Revoke'}.map( 74 | (String choice) { 75 | return PopupMenuItem( 76 | value: choice, 77 | child: Text(choice), 78 | ); 79 | }, 80 | ).toList(); 81 | }, 82 | ), 83 | ], 84 | ), 85 | ), 86 | ), 87 | ), 88 | ); 89 | } 90 | } 91 | 92 | class AuthorizationLabel extends StatelessWidget { 93 | AuthorizationLabel( 94 | this.value, 95 | ) : super(key: ValueKey(value)); 96 | 97 | final MicropubAuthorization value; 98 | 99 | @override 100 | Widget build(BuildContext context) { 101 | final theme = AppTheme.of(context); 102 | return Padding( 103 | padding: EdgeInsets.only(right: theme.spacing.small), 104 | child: Container( 105 | padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 4), 106 | decoration: BoxDecoration( 107 | borderRadius: BorderRadius.circular(4), 108 | color: () { 109 | switch (value) { 110 | case MicropubAuthorization.read: 111 | return theme.color.readAccent; 112 | case MicropubAuthorization.write: 113 | return theme.color.writeAccent; 114 | case MicropubAuthorization.admin: 115 | return theme.color.adminAccent; 116 | } 117 | }(), 118 | ), 119 | child: Text( 120 | () { 121 | switch (value) { 122 | case MicropubAuthorization.read: 123 | return 'DOWNLOAD'; 124 | case MicropubAuthorization.write: 125 | return 'PUBLISH'; 126 | case MicropubAuthorization.admin: 127 | return 'ADMIN'; 128 | } 129 | }(), 130 | style: theme.typography.paragraph3.copyWith( 131 | color: Colors.white, 132 | ), 133 | ), 134 | ), 135 | ); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /micropub/lib/src/client/client.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:http/http.dart'; 5 | import 'package:http_parser/http_parser.dart'; 6 | import 'package:micropub/src/shared/model.dart'; 7 | 8 | class MicropubApiClient { 9 | const MicropubApiClient({ 10 | this.baseUri = '/', 11 | }); 12 | 13 | final String baseUri; 14 | 15 | Future info() { 16 | return _get('/info', (x) => MicropubServerInfo.fromJson(x)); 17 | } 18 | 19 | Uri _buildUri( 20 | String path, { 21 | Map query = const {}, 22 | }) { 23 | final queryUri = query.isEmpty 24 | ? '' 25 | : '?' + 26 | Uri( 27 | queryParameters: query, 28 | ).query; 29 | final baseUri = this.baseUri.endsWith('/') 30 | ? this.baseUri.substring(0, this.baseUri.length - 1) 31 | : this.baseUri; 32 | return Uri.parse('$baseUri/api$path$queryUri'); 33 | } 34 | 35 | Future _get( 36 | String path, 37 | T Function(dynamic body) deserialize, { 38 | Map query = const {}, 39 | }) async { 40 | final result = await get( 41 | _buildUri(path, query: query), 42 | headers: headers, 43 | ); 44 | return deserialize(jsonDecode(result.body)); 45 | } 46 | 47 | Future _post( 48 | String path, 49 | T Function(dynamic body) deserialize, { 50 | Map query = const {}, 51 | dynamic body, 52 | }) async { 53 | final result = await post( 54 | _buildUri(path, query: query), 55 | body: body != null ? jsonEncode(body) : null, 56 | headers: headers, 57 | ); 58 | return deserialize(jsonDecode(result.body)); 59 | } 60 | 61 | Future _delete( 62 | String path, 63 | T Function(dynamic body) deserialize, { 64 | Map query = const {}, 65 | dynamic body, 66 | }) async { 67 | final result = await delete( 68 | _buildUri(path, query: query), 69 | body: body != null ? jsonEncode(body) : null, 70 | headers: headers, 71 | ); 72 | return deserialize(jsonDecode(result.body)); 73 | } 74 | 75 | Map get headers => {'content-type': 'application/json'}; 76 | } 77 | 78 | class MicropubApiAuthenticatedClient extends MicropubApiClient { 79 | const MicropubApiAuthenticatedClient({ 80 | required this.accessKey, 81 | String baseUri = '/', 82 | }) : super(baseUri: baseUri); 83 | 84 | final String accessKey; 85 | 86 | Future me() { 87 | return _get('/me', (x) => MicropubMe.fromJson(x)); 88 | } 89 | 90 | Future getPackages({ 91 | int? size, 92 | int? page, 93 | String? sort, 94 | String? query, 95 | }) { 96 | return _get( 97 | '/packages', 98 | (x) { 99 | return MicropubQueryResult.fromJson(x); 100 | }, 101 | query: { 102 | if (size != null) 'size': size.toString(), 103 | if (page != null) 'page': page.toString(), 104 | if (sort != null) 'sort': sort.toString(), 105 | if (query != null) 'q': query, 106 | }, 107 | ); 108 | } 109 | 110 | Future getPackageDetails(String name) { 111 | return _get( 112 | '/packages/${Uri.encodeComponent(name)}/details', 113 | (x) => MicropubPackageDetails.fromJson(x), 114 | ); 115 | } 116 | 117 | Future> adminAccessKeys() { 118 | return _get( 119 | '/admin/users', 120 | (x) => [ 121 | ...(x as List).map((e) => MicropubAccessKey.fromJson(e)), 122 | ], 123 | ); 124 | } 125 | 126 | Future adminCreateAccessKey( 127 | String email, 128 | bool read, 129 | bool write, 130 | bool admin, 131 | ) { 132 | return _post( 133 | '/admin/users', 134 | (x) => MicropubAccessKey.fromJson(x), 135 | body: { 136 | 'email': email, 137 | 'authorizations': [ 138 | if (read) 'read', 139 | if (write) 'write', 140 | if (admin) 'admin', 141 | ] 142 | }, 143 | ); 144 | } 145 | 146 | Future adminRevokeAccessKey(String key) { 147 | return _delete( 148 | '/admin/users/$key', 149 | (x) {}, 150 | ); 151 | } 152 | 153 | Future uploadPackage({ 154 | required String name, 155 | required String version, 156 | required List archive, 157 | }) async { 158 | final gzipArchive = GZipCodec().encode(archive); 159 | 160 | final file = MultipartFile( 161 | 'file', 162 | Stream.fromIterable([gzipArchive]), 163 | gzipArchive.length, 164 | filename: '$name-$version.tar', 165 | contentType: MediaType('application', 'x-tar'), 166 | ); 167 | 168 | final request = 169 | MultipartRequest("POST", _buildUri('/packages/versions/newUpload')); 170 | request.headers.addAll({ 171 | 'authorization': 'bearer $accessKey', 172 | }); 173 | request.files.add(file); 174 | final response = await request.send(); 175 | if (response.statusCode == 302) { 176 | print("Uploaded!"); 177 | await response.stream.toList(); 178 | } else { 179 | throw Exception('Upload failed with code ${response.statusCode}'); 180 | } 181 | } 182 | 183 | @override 184 | Map get headers => { 185 | ...super.headers, 186 | 'authorization': 'bearer $accessKey', 187 | }; 188 | } 189 | -------------------------------------------------------------------------------- /website/lib/views/packages/widgets/action_bar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:gap/gap.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | import 'package:provider/provider.dart'; 5 | import 'package:tap_builder/tap_builder.dart'; 6 | import 'package:website/state/notifier.dart'; 7 | import 'package:website/theme/theme.dart'; 8 | import 'package:website/widgets/text_button.dart'; 9 | 10 | class AppActionBar extends StatelessWidget { 11 | const AppActionBar({ 12 | Key? key, 13 | }) : super(key: key); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | final theme = AppTheme.of(context); 18 | final appState = context.watch().value; 19 | final isAdmin = appState.maybeMap( 20 | authenticated: (authenticated) => authenticated.admin.maybeMap( 21 | notAuthorized: (_) => false, 22 | orElse: () => true, 23 | ), 24 | orElse: () => false, 25 | ); 26 | return DefaultTextStyle( 27 | style: theme.typography.title3.copyWith( 28 | color: theme.color.barBarText1, 29 | ), 30 | child: Container( 31 | color: theme.color.barBarBackground, 32 | width: double.infinity, 33 | child: LayoutBuilder(builder: (context, constraints) { 34 | return Stack( 35 | alignment: Alignment.centerLeft, 36 | children: [ 37 | const _Logo(), 38 | if (constraints.maxWidth > theme.size.maxWidth) 39 | Positioned.fill( 40 | child: Center( 41 | child: Text( 42 | appState.maybeMap( 43 | authenticated: (authenticated) => 44 | 'Welcome ${authenticated.me.email}!', 45 | orElse: () => '', 46 | ), 47 | style: theme.typography.paragraph3.copyWith( 48 | color: theme.color.heroBarFieldPlaceholder, 49 | ), 50 | ), 51 | ), 52 | ), 53 | Positioned( 54 | right: 0, 55 | top: 0, 56 | bottom: 0, 57 | child: Row( 58 | mainAxisSize: MainAxisSize.min, 59 | children: [ 60 | if (isAdmin) ...[ 61 | AppTextButton( 62 | title: 'Admin', 63 | onTap: () { 64 | context.go('/admin'); 65 | }, 66 | ), 67 | Gap(theme.spacing.regular), 68 | ], 69 | AppTextButton( 70 | title: 'Disconnect', 71 | onTap: () { 72 | final notifier = AppStateNotifier.of(context); 73 | notifier.disconnect(); 74 | }, 75 | ), 76 | ], 77 | ), 78 | ), 79 | ], 80 | ); 81 | }), 82 | ), 83 | ); 84 | } 85 | } 86 | 87 | class _Logo extends StatelessWidget { 88 | const _Logo({ 89 | Key? key, 90 | }) : super(key: key); 91 | 92 | @override 93 | Widget build(BuildContext context) { 94 | final theme = AppTheme.of(context); 95 | final name = context 96 | .read() 97 | .value 98 | .map( 99 | initializing: (x) => null, 100 | initializationFailed: (x) => null, 101 | initialized: (x) => x.info, 102 | authenticated: (x) => x.info, 103 | authenticating: (x) => x.info, 104 | authenticationFailed: (x) => x.info, 105 | notAuthenticated: (x) => x.info, 106 | ) 107 | ?.name; 108 | return TapBuilder( 109 | onTap: () => context.go('/'), 110 | builder: (context, state, isFocus) { 111 | final opacity = () { 112 | switch (state) { 113 | case TapState.pressed: 114 | return 0.14; 115 | case TapState.hover: 116 | return 0.08; 117 | default: 118 | return 0.0; 119 | } 120 | }(); 121 | return AnimatedContainer( 122 | duration: const Duration(milliseconds: 200), 123 | color: theme.color.barBarText1.withOpacity(opacity), 124 | padding: EdgeInsets.all(theme.spacing.regular), 125 | child: Row( 126 | mainAxisSize: MainAxisSize.min, 127 | crossAxisAlignment: CrossAxisAlignment.center, 128 | children: [ 129 | Text( 130 | 'micro', 131 | style: theme.typography.title3.copyWith( 132 | fontWeight: FontWeight.w300, 133 | ), 134 | ), 135 | Text( 136 | 'pub', 137 | style: theme.typography.title3, 138 | ), 139 | if (name != null) 140 | Padding( 141 | padding: EdgeInsets.only(left: theme.spacing.small), 142 | child: Text( 143 | name, 144 | style: theme.typography.paragraph3.copyWith( 145 | color: theme.color.heroBarFieldPlaceholder, 146 | ), 147 | ), 148 | ), 149 | ], 150 | ), 151 | ); 152 | }, 153 | ); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /micropub/lib/src/shared/model.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'model.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$_MicropubVersion _$$_MicropubVersionFromJson(Map json) => _$_MicropubVersion( 10 | version: json['version'] as String, 11 | pubspec: Map.from(json['pubspec'] as Map), 12 | pubspecYaml: json['pubspecYaml'] as String?, 13 | uploader: json['uploader'] as String?, 14 | readme: json['readme'] as String?, 15 | changelog: json['changelog'] as String?, 16 | createdAt: jsonToDateTime(json['createdAt']), 17 | ); 18 | 19 | Map _$$_MicropubVersionToJson(_$_MicropubVersion instance) => 20 | { 21 | 'version': instance.version, 22 | 'pubspec': instance.pubspec, 23 | 'pubspecYaml': instance.pubspecYaml, 24 | 'uploader': instance.uploader, 25 | 'readme': instance.readme, 26 | 'changelog': instance.changelog, 27 | 'createdAt': jsonFromDateTime(instance.createdAt), 28 | }; 29 | 30 | _$_MicropubPackage _$$_MicropubPackageFromJson(Map json) => _$_MicropubPackage( 31 | name: json['name'] as String, 32 | versions: (json['versions'] as List) 33 | .map((e) => 34 | MicropubVersion.fromJson(Map.from(e as Map))) 35 | .toList(), 36 | uploaders: (json['uploaders'] as List?) 37 | ?.map((e) => e as String) 38 | .toList(), 39 | download: json['download'] as int?, 40 | createdAt: jsonToDateTime(json['createdAt']), 41 | updatedAt: jsonToDateTime(json['updatedAt']), 42 | ); 43 | 44 | Map _$$_MicropubPackageToJson(_$_MicropubPackage instance) => 45 | { 46 | 'name': instance.name, 47 | 'versions': instance.versions.map((e) => e.toJson()).toList(), 48 | 'uploaders': instance.uploaders, 49 | 'download': instance.download, 50 | 'createdAt': jsonFromDateTime(instance.createdAt), 51 | 'updatedAt': jsonFromDateTime(instance.updatedAt), 52 | }; 53 | 54 | _$_MicropubPackageDetails _$$_MicropubPackageDetailsFromJson(Map json) => 55 | _$_MicropubPackageDetails( 56 | package: MicropubPackage.fromJson( 57 | Map.from(json['package'] as Map)), 58 | readme: json['readme'] as String?, 59 | ); 60 | 61 | Map _$$_MicropubPackageDetailsToJson( 62 | _$_MicropubPackageDetails instance) => 63 | { 64 | 'package': instance.package.toJson(), 65 | 'readme': instance.readme, 66 | }; 67 | 68 | _$_MicropubMe _$$_MicropubMeFromJson(Map json) => _$_MicropubMe( 69 | email: json['email'] as String, 70 | authorizations: (json['authorizations'] as List) 71 | .map((e) => $enumDecode(_$MicropubAuthorizationEnumMap, e)) 72 | .toList(), 73 | ); 74 | 75 | Map _$$_MicropubMeToJson(_$_MicropubMe instance) => 76 | { 77 | 'email': instance.email, 78 | 'authorizations': instance.authorizations 79 | .map((e) => _$MicropubAuthorizationEnumMap[e]) 80 | .toList(), 81 | }; 82 | 83 | const _$MicropubAuthorizationEnumMap = { 84 | MicropubAuthorization.admin: 'admin', 85 | MicropubAuthorization.read: 'read', 86 | MicropubAuthorization.write: 'write', 87 | }; 88 | 89 | _$_MicropubQueryResult _$$_MicropubQueryResultFromJson(Map json) => 90 | _$_MicropubQueryResult( 91 | count: json['count'] as int, 92 | packages: (json['packages'] as List) 93 | .map((e) => 94 | MicropubPackage.fromJson(Map.from(e as Map))) 95 | .toList(), 96 | ); 97 | 98 | Map _$$_MicropubQueryResultToJson( 99 | _$_MicropubQueryResult instance) => 100 | { 101 | 'count': instance.count, 102 | 'packages': instance.packages.map((e) => e.toJson()).toList(), 103 | }; 104 | 105 | _$_MicropubAccessKey _$$_MicropubAccessKeyFromJson(Map json) => 106 | _$_MicropubAccessKey( 107 | id: json['id'] as String, 108 | key: json['key'] as String, 109 | email: json['email'] as String, 110 | creationDate: DateTime.parse(json['creationDate'] as String), 111 | authorizations: (json['authorizations'] as List) 112 | .map((e) => $enumDecode(_$MicropubAuthorizationEnumMap, e)) 113 | .toList(), 114 | ); 115 | 116 | Map _$$_MicropubAccessKeyToJson( 117 | _$_MicropubAccessKey instance) => 118 | { 119 | 'id': instance.id, 120 | 'key': instance.key, 121 | 'email': instance.email, 122 | 'creationDate': instance.creationDate.toIso8601String(), 123 | 'authorizations': instance.authorizations 124 | .map((e) => _$MicropubAuthorizationEnumMap[e]) 125 | .toList(), 126 | }; 127 | 128 | _$_MicropubServerInfo _$$_MicropubServerInfoFromJson(Map json) => 129 | _$_MicropubServerInfo( 130 | name: json['name'] as String?, 131 | adminEmail: json['adminEmail'] as String, 132 | distantHostUrl: json['distantHostUrl'] as String, 133 | ); 134 | 135 | Map _$$_MicropubServerInfoToJson( 136 | _$_MicropubServerInfo instance) => 137 | { 138 | 'name': instance.name, 139 | 'adminEmail': instance.adminEmail, 140 | 'distantHostUrl': instance.distantHostUrl, 141 | }; 142 | -------------------------------------------------------------------------------- /website/lib/views/package/widgets/header.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:provider/provider.dart'; 4 | import 'package:tap_builder/tap_builder.dart'; 5 | import 'package:micropub/client.dart'; 6 | import 'package:website/state/notifier.dart'; 7 | import 'package:website/theme/theme.dart'; 8 | import 'package:timeago/timeago.dart' as timeago; 9 | 10 | import 'content_entry.dart'; 11 | 12 | class PackageHeader extends StatelessWidget { 13 | const PackageHeader({ 14 | Key? key, 15 | required this.package, 16 | }) : super(key: key); 17 | 18 | final MicropubPackage package; 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | final theme = AppTheme.of(context); 23 | final lastVersion = package.versions.first; 24 | final createdAt = lastVersion.createdAt; 25 | return ContentEntry( 26 | child: Padding( 27 | padding: EdgeInsets.only( 28 | top: theme.spacing.extraBig, 29 | left: theme.spacing.small, 30 | right: theme.spacing.small, 31 | bottom: theme.spacing.regular, 32 | ), 33 | child: Column( 34 | crossAxisAlignment: CrossAxisAlignment.stretch, 35 | mainAxisSize: MainAxisSize.min, 36 | children: [ 37 | Row( 38 | mainAxisSize: MainAxisSize.min, 39 | children: [ 40 | Text( 41 | '${package.name} ${lastVersion.version}', 42 | style: theme.typography.title2, 43 | ), 44 | CopyRefButton( 45 | package: package, 46 | ), 47 | ], 48 | ), 49 | Wrap( 50 | children: [ 51 | Text( 52 | 'Published', 53 | style: theme.typography.paragraph2, 54 | ), 55 | if (createdAt != null) ...[ 56 | Text( 57 | ' ${timeago.format(createdAt)}', 58 | style: theme.typography.paragraph2, 59 | ), 60 | ], 61 | if (lastVersion.uploader != null) ...[ 62 | Text( 63 | ' by ', 64 | style: theme.typography.paragraph2, 65 | ), 66 | Text( 67 | '${lastVersion.uploader}', 68 | style: theme.typography.paragraph2.copyWith( 69 | color: theme.color.bodyAccentText1, 70 | ), 71 | ), 72 | ], 73 | ], 74 | ), 75 | ], 76 | ), 77 | ), 78 | ); 79 | } 80 | } 81 | 82 | class CopyRefButton extends StatefulWidget { 83 | const CopyRefButton({ 84 | Key? key, 85 | required this.package, 86 | }) : super(key: key); 87 | 88 | final MicropubPackage package; 89 | 90 | @override 91 | State createState() => _CopyRefButtonState(); 92 | } 93 | 94 | class _CopyRefButtonState extends State { 95 | var isLabelVisible = false; 96 | 97 | void _showLabel() async { 98 | Clipboard.setData(ClipboardData(text: buildRef())); 99 | setState(() { 100 | isLabelVisible = true; 101 | }); 102 | await Future.delayed(const Duration(seconds: 4)); 103 | if (mounted) { 104 | setState(() { 105 | isLabelVisible = false; 106 | }); 107 | } 108 | } 109 | 110 | String buildRef() { 111 | final distantHostUrl = context 112 | .read() 113 | .value 114 | .map( 115 | initializing: (x) => null, 116 | initializationFailed: (x) => null, 117 | initialized: (x) => x.info, 118 | authenticated: (x) => x.info, 119 | authenticating: (x) => x.info, 120 | authenticationFailed: (x) => x.info, 121 | notAuthenticated: (x) => x.info, 122 | ) 123 | ?.distantHostUrl; 124 | return ''' 125 | ${widget.package.name}: 126 | hosted: $distantHostUrl 127 | version: ^${widget.package.versions.first.version} 128 | ''' 129 | .trim(); 130 | } 131 | 132 | @override 133 | Widget build(BuildContext context) { 134 | final theme = AppTheme.of(context); 135 | 136 | return Row( 137 | mainAxisSize: MainAxisSize.min, 138 | children: [ 139 | TapBuilder( 140 | onTap: _showLabel, 141 | builder: (BuildContext context, TapState state, bool isFocused) { 142 | final opacity = () { 143 | switch (state) { 144 | case TapState.pressed: 145 | return 0.14; 146 | case TapState.hover: 147 | return 0.08; 148 | default: 149 | return 0.0; 150 | } 151 | }(); 152 | return AnimatedContainer( 153 | duration: const Duration(milliseconds: 200), 154 | color: theme.color.bodyText1.withOpacity(opacity), 155 | padding: EdgeInsets.all(theme.spacing.small), 156 | child: Icon( 157 | Icons.copy, 158 | color: theme.color.bodyText1, 159 | ), 160 | ); 161 | }, 162 | ), 163 | AnimatedOpacity( 164 | duration: const Duration(milliseconds: 200), 165 | opacity: isLabelVisible ? 1 : 0, 166 | child: Container( 167 | color: theme.color.bodyText1.withOpacity(0.08), 168 | padding: const EdgeInsets.all(4), 169 | child: Text( 170 | 'Copied to clipboard: \n${buildRef()}', 171 | style: theme.typography.paragraph4, 172 | ), 173 | ), 174 | ), 175 | ], 176 | ); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /website/lib/views/admin/admin.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:micropub/client.dart'; 4 | import 'package:website/state/notifier.dart'; 5 | import 'package:website/theme/theme.dart'; 6 | import 'package:website/views/admin/widgets/access_key_tile.dart'; 7 | import 'package:website/views/admin/widgets/action_bar.dart'; 8 | import 'package:website/views/packages/widgets/action_bar.dart'; 9 | import 'package:website/widgets/flat_button.dart'; 10 | import 'package:website/widgets/footer.dart'; 11 | 12 | import 'widgets/create_key_dialog.dart'; 13 | 14 | class AdminView extends StatefulWidget { 15 | const AdminView({ 16 | Key? key, 17 | }) : super(key: key); 18 | 19 | @override 20 | State createState() => _AdminViewState(); 21 | } 22 | 23 | class _AdminViewState extends State { 24 | @override 25 | void initState() { 26 | WidgetsBinding.instance?.addPostFrameCallback((timeStamp) { 27 | context.read().refreshAdmin(); 28 | }); 29 | super.initState(); 30 | } 31 | 32 | @override 33 | Widget build(BuildContext context) { 34 | final theme = AppTheme.of(context); 35 | final appState = context.watch().value; 36 | return DefaultTextStyle( 37 | style: theme.typography.paragraph2.copyWith( 38 | color: theme.color.bodyText1, 39 | ), 40 | child: appState.map( 41 | initializing: (authenticating) => const _Loading(), 42 | initialized: (initialized) => const _Loading(), 43 | initializationFailed: (initializationFailed) => const _Failed(), 44 | notAuthenticated: (notAuthenticated) => const _NotAuthorized(), 45 | authenticationFailed: (authenticationFailed) => const _NotAuthorized(), 46 | authenticating: (authenticating) => const _Loading(), 47 | authenticated: (authenticated) => authenticated.admin.map( 48 | notAuthorized: (notAuthenticated) => const _NotAuthorized(), 49 | notLoaded: (notLoaded) => const _Loading(), 50 | loading: (loading) => const _Loading(), 51 | failed: (failed) => const _Failed(), 52 | empty: (empty) => const _Empty(), 53 | result: (result) => _Results( 54 | accessKeys: result.accessKeys, 55 | ), 56 | ), 57 | ), 58 | ); 59 | } 60 | } 61 | 62 | class _NotAuthorized extends StatelessWidget { 63 | const _NotAuthorized({ 64 | Key? key, 65 | }) : super(key: key); 66 | 67 | @override 68 | Widget build(BuildContext context) { 69 | final theme = AppTheme.of(context); 70 | return Material( 71 | color: theme.color.bodyBackground, 72 | child: Column( 73 | children: const [ 74 | AppActionBar(), 75 | Expanded( 76 | child: Center( 77 | child: Text('Not authorized!'), 78 | ), 79 | ), 80 | AppFooter(), 81 | ], 82 | ), 83 | ); 84 | } 85 | } 86 | 87 | class _Failed extends StatelessWidget { 88 | const _Failed({ 89 | Key? key, 90 | }) : super(key: key); 91 | 92 | @override 93 | Widget build(BuildContext context) { 94 | final theme = AppTheme.of(context); 95 | return Material( 96 | color: theme.color.bodyBackground, 97 | child: Column( 98 | children: const [ 99 | AppActionBar(), 100 | Expanded( 101 | child: Center( 102 | child: Text('An error occured...'), 103 | ), 104 | ), 105 | AppFooter(), 106 | ], 107 | ), 108 | ); 109 | } 110 | } 111 | 112 | class _Loading extends StatelessWidget { 113 | const _Loading({ 114 | Key? key, 115 | }) : super(key: key); 116 | 117 | @override 118 | Widget build(BuildContext context) { 119 | final theme = AppTheme.of(context); 120 | return Material( 121 | color: theme.color.barBarBackground, 122 | child: Column( 123 | children: const [ 124 | AppActionBar(), 125 | Expanded( 126 | child: Center( 127 | child: CircularProgressIndicator(), 128 | ), 129 | ), 130 | AppFooter(), 131 | ], 132 | ), 133 | ); 134 | } 135 | } 136 | 137 | class _Empty extends StatelessWidget { 138 | const _Empty({ 139 | Key? key, 140 | }) : super(key: key); 141 | 142 | @override 143 | Widget build(BuildContext context) { 144 | final theme = AppTheme.of(context); 145 | return Material( 146 | color: theme.color.bodyBackground, 147 | child: Column( 148 | children: [ 149 | const AppActionBar(), 150 | Expanded( 151 | child: Center( 152 | child: AppFlatButton( 153 | title: 'Create a key', 154 | onTap: () => CreateAccessKeyDialog.show(context), 155 | ), 156 | ), 157 | ), 158 | const AppFooter(), 159 | ], 160 | ), 161 | ); 162 | } 163 | } 164 | 165 | class _Results extends StatelessWidget { 166 | const _Results({ 167 | Key? key, 168 | required this.accessKeys, 169 | }) : super(key: key); 170 | 171 | final List accessKeys; 172 | 173 | @override 174 | Widget build(BuildContext context) { 175 | final theme = AppTheme.of(context); 176 | return Material( 177 | color: theme.color.barBarBackground, 178 | child: ListView( 179 | children: [ 180 | const AppActionBar(), 181 | AdminActionBar( 182 | count: accessKeys.length, 183 | ), 184 | ...accessKeys.map( 185 | (e) => AccessKeyTile( 186 | key: Key(e.key), 187 | accessKey: e, 188 | ), 189 | ), 190 | const AppFooter(), 191 | ], 192 | ), 193 | ); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /website/lib/views/package/package.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:micropub/client.dart'; 4 | import 'package:website/state/notifier.dart'; 5 | import 'package:website/theme/theme.dart'; 6 | import 'package:website/views/package/widgets/content_entry.dart'; 7 | import 'package:website/views/package/widgets/tabs/installing.dart'; 8 | import 'package:website/views/package/widgets/tabs/readme.dart'; 9 | import 'package:website/views/package/widgets/tabs/versions.dart'; 10 | import 'package:website/views/packages/widgets/action_bar.dart'; 11 | import 'package:website/widgets/footer.dart'; 12 | 13 | import 'widgets/header.dart'; 14 | import 'widgets/tabs.dart'; 15 | import 'widgets/tabs/dependencies.dart'; 16 | 17 | class PackageView extends StatefulWidget { 18 | const PackageView({ 19 | Key? key, 20 | required this.packageName, 21 | }) : super(key: key); 22 | 23 | final String packageName; 24 | 25 | @override 26 | State createState() => _PackageViewState(); 27 | } 28 | 29 | class _PackageViewState extends State { 30 | @override 31 | void initState() { 32 | WidgetsBinding.instance?.addPostFrameCallback((timeStamp) { 33 | context.read().loadPackage(widget.packageName); 34 | }); 35 | super.initState(); 36 | } 37 | 38 | @override 39 | Widget build(BuildContext context) { 40 | final theme = AppTheme.of(context); 41 | final appState = context.watch().value; 42 | 43 | return DefaultTextStyle( 44 | style: theme.typography.paragraph2.copyWith( 45 | color: theme.color.bodyText1, 46 | ), 47 | child: appState.map( 48 | initializing: (initializing) => const _Loading(), 49 | initialized: (initialized) => const _Loading(), 50 | initializationFailed: (failed) => _Failed( 51 | description: 'Initialization failed\n${failed.error}', 52 | ), 53 | notAuthenticated: (notAuthenticated) => const _Failed( 54 | description: 'Not authenticated', 55 | ), 56 | authenticationFailed: (authenticationFailed) => const _Failed( 57 | description: 'Not authenticated', 58 | ), 59 | authenticating: (authenticating) => const _Loading(), 60 | authenticated: (authenticated) => authenticated.package.map( 61 | notLoaded: (notLoaded) => const _Loading(), 62 | loading: (loading) => const _Loading(), 63 | failed: (failed) => _Failed( 64 | description: 'Loading failed\n${failed.error}', 65 | ), 66 | result: (result) => _Results(package: result.result), 67 | ), 68 | ), 69 | ); 70 | } 71 | } 72 | 73 | class _Failed extends StatelessWidget { 74 | const _Failed({ 75 | Key? key, 76 | required this.description, 77 | }) : super(key: key); 78 | 79 | final String description; 80 | 81 | @override 82 | Widget build(BuildContext context) { 83 | final theme = AppTheme.of(context); 84 | return Material( 85 | color: theme.color.bodyBackground, 86 | child: Column( 87 | children: [ 88 | const AppActionBar(), 89 | Expanded( 90 | child: Center( 91 | child: Text( 92 | description, 93 | style: theme.typography.title3, 94 | ), 95 | ), 96 | ), 97 | const AppFooter(), 98 | ], 99 | ), 100 | ); 101 | } 102 | } 103 | 104 | class _Loading extends StatelessWidget { 105 | const _Loading({ 106 | Key? key, 107 | }) : super(key: key); 108 | 109 | @override 110 | Widget build(BuildContext context) { 111 | final theme = AppTheme.of(context); 112 | return Material( 113 | color: theme.color.bodyBackground, 114 | child: Column( 115 | children: const [ 116 | AppActionBar(), 117 | Expanded( 118 | child: Center( 119 | child: CircularProgressIndicator(), 120 | ), 121 | ), 122 | AppFooter(), 123 | ], 124 | ), 125 | ); 126 | } 127 | } 128 | 129 | class _Results extends StatefulWidget { 130 | const _Results({ 131 | Key? key, 132 | required this.package, 133 | }) : super(key: key); 134 | 135 | final MicropubPackageDetails package; 136 | 137 | @override 138 | State<_Results> createState() => _ResultsState(); 139 | } 140 | 141 | class _ResultsState extends State<_Results> { 142 | var selectedTab = 0; 143 | 144 | @override 145 | Widget build(BuildContext context) { 146 | final theme = AppTheme.of(context); 147 | return Container( 148 | color: theme.color.barBarBackground, 149 | child: CustomScrollView( 150 | slivers: [ 151 | SliverList( 152 | delegate: SliverChildListDelegate([ 153 | const AppActionBar(), 154 | PackageHeader( 155 | package: widget.package.package, 156 | ), 157 | ContentEntry( 158 | child: Padding( 159 | padding: EdgeInsets.only(bottom: theme.spacing.regular), 160 | child: PackageTabs( 161 | selectedIndex: selectedTab, 162 | onSelectedIndexChanged: (i) => setState(() { 163 | selectedTab = i; 164 | }), 165 | tabs: const [ 166 | 'Readme', 167 | 'Installing', 168 | 'Dependencies', 169 | 'Versions', 170 | ], 171 | ), 172 | ), 173 | ), 174 | ]), 175 | ), 176 | () { 177 | if (selectedTab == 0) { 178 | return ReadmeTab( 179 | package: widget.package, 180 | ); 181 | } 182 | 183 | if (selectedTab == 1) { 184 | return InstallingTab( 185 | package: widget.package, 186 | ); 187 | } 188 | 189 | if (selectedTab == 2) { 190 | return DependenciesTab( 191 | package: widget.package, 192 | ); 193 | } 194 | 195 | if (selectedTab == 3) { 196 | return VersionsTab( 197 | package: widget.package, 198 | ); 199 | } 200 | 201 | throw Exception(); 202 | }(), 203 | const SliverToBoxAdapter(child: AppFooter()), 204 | ], 205 | ), 206 | ); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /micropub/lib/src/server/storage/hive.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'package:archive/archive_io.dart'; 6 | import 'package:hive/hive.dart'; 7 | import 'package:path/path.dart' as path; 8 | import 'package:pub_semver/pub_semver.dart' as semver; 9 | import 'package:micropub/src/shared/model.dart'; 10 | import 'package:collection/collection.dart'; 11 | import 'package:micropub/src/server/storage/storage.dart'; 12 | 13 | class MicropubHiveStorage extends MicropubStorage { 14 | MicropubHiveStorage({ 15 | required this.directory, 16 | }); 17 | 18 | final Directory directory; 19 | final Box box = Hive.box('packages.micropub'); 20 | 21 | @override 22 | Future addUploader(String name, String email) async { 23 | final package = await _getPackage(name); 24 | await _putPackage( 25 | package.copyWith( 26 | uploaders: [ 27 | if (package.uploaders != null) ...package.uploaders!, 28 | email, 29 | ], 30 | ), 31 | ); 32 | } 33 | 34 | @override 35 | Future addVersion(String name, MicropubVersion version) async { 36 | final package = await _getPackage(name); 37 | 38 | final versions = [ 39 | ...package.versions, 40 | version, 41 | ]; 42 | 43 | versions.sort((a, b) { 44 | return semver.Version.prioritize( 45 | semver.Version.parse(a.version), semver.Version.parse(b.version)); 46 | }); 47 | 48 | await _putPackage( 49 | package.copyWith( 50 | versions: versions, 51 | ), 52 | ); 53 | } 54 | 55 | @override 56 | void increaseDownloads(String name, String version) async { 57 | if (!_hasPackage(name)) return null; 58 | final package = await _getPackage(name); 59 | await _putPackage( 60 | package.copyWith( 61 | download: (package.download ?? 0) + 1, 62 | ), 63 | ); 64 | } 65 | 66 | @override 67 | Future queryPackage(String name) async { 68 | if (!_hasPackage(name)) return null; 69 | return await _getPackage(name); 70 | } 71 | 72 | static MicropubQueryResult filterPackages( 73 | List packages, { 74 | required int size, 75 | required int page, 76 | required String sort, 77 | String? keyword, 78 | String? uploader, 79 | String? dependency, 80 | }) { 81 | var result = packages; 82 | 83 | // Keyword Filtering 84 | if (keyword != null && keyword.trim().isNotEmpty) { 85 | result = [ 86 | ...result.where( 87 | (x) => x.name.toLowerCase().contains( 88 | keyword.toLowerCase().trim(), 89 | ), 90 | ), 91 | ]; 92 | } 93 | 94 | // Uploader Filtering 95 | if (uploader != null && uploader.trim().isNotEmpty) { 96 | result = [ 97 | ...result.where( 98 | (x) => (x.uploaders ?? []).any( 99 | (x) => x.toLowerCase().trim() == uploader.toLowerCase().trim(), 100 | ), 101 | ), 102 | ]; 103 | } 104 | 105 | // Dependency Filtering 106 | if (dependency != null && dependency.trim().isNotEmpty) { 107 | result = [ 108 | ...result.where((x) => x.versions 109 | .any((version) => version.pubspec.containsKey(dependency))), 110 | ]; 111 | } 112 | 113 | // Ordering 114 | result = result.toList() 115 | ..sort( 116 | (x, y) { 117 | if (sort == 'updatedAt') { 118 | final dx = x.updatedAt; 119 | final dy = y.updatedAt; 120 | if (dx != null && dy != null) return dy.compareTo(dx); 121 | if (dy != null) return 1; 122 | if (dx != null) return -1; 123 | return 0; 124 | } 125 | return y.name.compareTo(x.name); 126 | }, 127 | ); 128 | 129 | return MicropubQueryResult( 130 | count: result.length, 131 | packages: [ 132 | // Pagination 133 | ...result.skip(size * page).take(size), 134 | ], 135 | ); 136 | } 137 | 138 | @override 139 | Future queryPackages({ 140 | required int size, 141 | required int page, 142 | required String sort, 143 | String? keyword, 144 | String? uploader, 145 | String? dependency, 146 | }) async { 147 | final allPackages = _getAllPackages(); 148 | return filterPackages( 149 | allPackages, 150 | size: size, 151 | page: page, 152 | sort: sort, 153 | keyword: keyword, 154 | uploader: uploader, 155 | dependency: dependency, 156 | ); 157 | } 158 | 159 | @override 160 | Future removeUploader(String name, String email) async { 161 | final package = await _getPackage(name); 162 | await _putPackage( 163 | package.copyWith( 164 | uploaders: [ 165 | if (package.uploaders != null) 166 | ...package.uploaders!.where((x) => x != email), 167 | ], 168 | ), 169 | ); 170 | } 171 | 172 | @override 173 | Stream> download(String name, String version) async* { 174 | final file = await _getPackageArchive(name, version); 175 | final content = await file.readAsBytes(); 176 | yield content; 177 | } 178 | 179 | @override 180 | Future upload(String name, String version, List content) async { 181 | final file = await _getPackageArchive(name, version); 182 | await file.writeAsBytes(content); 183 | } 184 | 185 | Future _getPackageArchive(String name, String version) async { 186 | final directory = Directory(path.join( 187 | this.directory.path, 188 | 'packages', 189 | )); 190 | if (!directory.existsSync()) { 191 | await directory.create(recursive: true); 192 | } 193 | 194 | return File(path.join(directory.path, '$name-$version')); 195 | } 196 | 197 | bool _hasPackage(String name) { 198 | return box.containsKey(name); 199 | } 200 | 201 | List _getAllPackages() { 202 | return box.values 203 | .map((data) => MicropubPackage.fromJson(_asNormalizedJson(data))) 204 | .toList(); 205 | } 206 | 207 | Future _getPackage(String name) async { 208 | final data = await box.get(name); 209 | 210 | return data != null 211 | ? MicropubPackage.fromJson(_asNormalizedJson(data)) 212 | : MicropubPackage( 213 | name: name, 214 | versions: [], 215 | download: 0, 216 | createdAt: DateTime.now(), 217 | updatedAt: DateTime.now(), 218 | uploaders: [], 219 | ); 220 | } 221 | 222 | Future _putPackage(MicropubPackage package) async { 223 | await box.put(package.name, package.toJson()); 224 | } 225 | } 226 | 227 | dynamic _asNormalizedJson(dynamic value) { 228 | if (value is Map) { 229 | return value.map( 230 | (key, value) { 231 | return MapEntry(key.toString(), _asNormalizedJson(value)); 232 | }, 233 | ); 234 | } 235 | if (value is List) { 236 | return [ 237 | ...value.map(_asNormalizedJson), 238 | ]; 239 | } 240 | return value; 241 | } 242 | -------------------------------------------------------------------------------- /website/lib/views/packages/packages.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:provider/provider.dart'; 3 | import 'package:micropub/client.dart'; 4 | import 'package:website/state/notifier.dart'; 5 | import 'package:website/theme/theme.dart'; 6 | import 'package:website/views/package/widgets/content_entry.dart'; 7 | import 'package:website/views/packages/widgets/action_bar.dart'; 8 | import 'package:website/widgets/footer.dart'; 9 | 10 | import 'widgets/package_tile.dart'; 11 | import 'widgets/page_selector.dart'; 12 | import 'widgets/result_overview.dart'; 13 | import 'widgets/search_bar.dart'; 14 | 15 | class PackagesView extends StatelessWidget { 16 | const PackagesView({ 17 | Key? key, 18 | }) : super(key: key); 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | final appState = context.watch().value; 23 | final theme = AppTheme.of(context); 24 | return DefaultTextStyle( 25 | style: theme.typography.paragraph2.copyWith( 26 | color: theme.color.bodyText1, 27 | ), 28 | child: appState.map( 29 | initializing: (initializing) => const _Loading(), 30 | initialized: (initialized) => const _Loading(), 31 | initializationFailed: (failed) => _Failed( 32 | description: 'Initialization failed\n${failed.error}', 33 | ), 34 | notAuthenticated: (notAuthenticated) => const _Failed( 35 | description: 'Not authenticated', 36 | ), 37 | authenticationFailed: (authenticationFailed) => const _Failed( 38 | description: 'Not authenticated', 39 | ), 40 | authenticating: (authenticating) => const _Loading(), 41 | authenticated: (authenticated) => authenticated.packages.map( 42 | notLoaded: (notLoaded) => const _NotLoaded(), 43 | empty: (empty) => _Empty(query: empty.query), 44 | loading: (loading) => const _Loading(), 45 | failed: (failed) => _Failed( 46 | description: 'Loading failed\n${failed.error}', 47 | ), 48 | result: (result) { 49 | if (result.result.count == 0) return _Empty(query: result.query); 50 | return _Results( 51 | result: result.result, 52 | pageSize: authenticated.packages.pageSize, 53 | page: authenticated.packages.page, 54 | query: authenticated.packages.query, 55 | ); 56 | }, 57 | ), 58 | ), 59 | ); 60 | } 61 | } 62 | 63 | class _NotLoaded extends StatefulWidget { 64 | const _NotLoaded({ 65 | Key? key, 66 | }) : super(key: key); 67 | 68 | @override 69 | State<_NotLoaded> createState() => _NotLoadedState(); 70 | } 71 | 72 | class _NotLoadedState extends State<_NotLoaded> { 73 | @override 74 | void initState() { 75 | WidgetsBinding.instance?.addPostFrameCallback((timeStamp) { 76 | context.read().loadPackages(0, ''); 77 | }); 78 | super.initState(); 79 | } 80 | 81 | @override 82 | Widget build(BuildContext context) { 83 | return const _Loading(); 84 | } 85 | } 86 | 87 | class _Failed extends StatelessWidget { 88 | const _Failed({ 89 | Key? key, 90 | required this.description, 91 | }) : super(key: key); 92 | 93 | final String description; 94 | 95 | @override 96 | Widget build(BuildContext context) { 97 | final theme = AppTheme.of(context); 98 | return Material( 99 | color: theme.color.bodyBackground, 100 | child: Column( 101 | children: [ 102 | const AppActionBar(), 103 | Expanded( 104 | child: Center( 105 | child: Text( 106 | description, 107 | style: theme.typography.title3, 108 | ), 109 | ), 110 | ), 111 | const AppFooter(), 112 | ], 113 | ), 114 | ); 115 | } 116 | } 117 | 118 | class _Empty extends StatelessWidget { 119 | const _Empty({ 120 | Key? key, 121 | required this.query, 122 | }) : super(key: key); 123 | 124 | final String query; 125 | 126 | @override 127 | Widget build(BuildContext context) { 128 | final theme = AppTheme.of(context); 129 | return Material( 130 | color: theme.color.bodyBackground, 131 | child: Column( 132 | children: [ 133 | const AppActionBar(), 134 | PackagesSearchBar( 135 | initialQuery: query, 136 | ), 137 | const Expanded( 138 | child: Center( 139 | child: Text('No packages'), 140 | ), 141 | ), 142 | const AppFooter(), 143 | ], 144 | ), 145 | ); 146 | } 147 | } 148 | 149 | class _Loading extends StatelessWidget { 150 | const _Loading({ 151 | Key? key, 152 | }) : super(key: key); 153 | 154 | @override 155 | Widget build(BuildContext context) { 156 | final theme = AppTheme.of(context); 157 | return Material( 158 | color: theme.color.bodyBackground, 159 | child: Column( 160 | children: const [ 161 | AppActionBar(), 162 | Expanded( 163 | child: Center( 164 | child: CircularProgressIndicator(), 165 | ), 166 | ), 167 | AppFooter(), 168 | ], 169 | ), 170 | ); 171 | } 172 | } 173 | 174 | class _Results extends StatelessWidget { 175 | const _Results({ 176 | Key? key, 177 | required this.page, 178 | required this.pageSize, 179 | required this.query, 180 | required this.result, 181 | }) : super(key: key); 182 | 183 | final MicropubQueryResult result; 184 | final int pageSize; 185 | final int page; 186 | final String query; 187 | 188 | @override 189 | Widget build(BuildContext context) { 190 | final theme = AppTheme.of(context); 191 | return DefaultTextStyle( 192 | style: theme.typography.paragraph2.copyWith( 193 | color: theme.color.bodyText1, 194 | ), 195 | child: Container( 196 | color: theme.color.barBarBackground, 197 | child: ListView( 198 | children: [ 199 | const AppActionBar(), 200 | PackagesSearchBar( 201 | initialQuery: query, 202 | ), 203 | PackagesResultOverview( 204 | count: result.count, 205 | ), 206 | ...result.packages.map( 207 | (p) => PackageTile( 208 | package: p, 209 | ), 210 | ), 211 | ContentEntry( 212 | child: Padding( 213 | padding: EdgeInsets.symmetric( 214 | vertical: theme.spacing.regular, 215 | ), 216 | child: PageSelector( 217 | selected: page, 218 | total: (result.count / pageSize).ceil(), 219 | onSelectedChanged: (i) { 220 | context.read().loadPackages(i, query); 221 | }, 222 | ), 223 | ), 224 | ), 225 | const AppFooter(), 226 | ], 227 | ), 228 | ), 229 | ); 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /website/lib/views/authentication/authentication.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/gestures.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:gap/gap.dart'; 4 | import 'package:provider/provider.dart'; 5 | import 'package:url_launcher/url_launcher.dart'; 6 | import 'package:website/state/notifier.dart'; 7 | import 'package:website/theme/theme.dart'; 8 | import 'package:website/widgets/flat_button.dart'; 9 | import 'package:website/widgets/text_button.dart'; 10 | 11 | import 'widgets/password_field.dart'; 12 | 13 | class AuthenticationView extends StatelessWidget { 14 | const AuthenticationView({ 15 | Key? key, 16 | }) : super(key: key); 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | final state = context.watch().value; 21 | 22 | return state.map( 23 | initializing: (initializing) => const _Loading(), 24 | initialized: (initialized) => const _Loading(), 25 | initializationFailed: (initializationFailed) => _Failed( 26 | message: 'Initialization failed\n${initializationFailed.error}', 27 | ), 28 | notAuthenticated: (notAuthenticated) => const _Success(), 29 | authenticationFailed: (authenticationFailed) => const _Success(), 30 | authenticating: (authenticating) => const _Loading(), 31 | authenticated: (authenticated) => const _Loading(), 32 | ); 33 | } 34 | } 35 | 36 | class _Loading extends StatelessWidget { 37 | const _Loading({ 38 | Key? key, 39 | }) : super(key: key); 40 | 41 | @override 42 | Widget build(BuildContext context) { 43 | final theme = AppTheme.of(context); 44 | return DefaultTextStyle( 45 | style: TextStyle(color: theme.color.heroBarText1), 46 | child: Container( 47 | color: theme.color.heroBarBackground, 48 | child: const Center( 49 | child: CircularProgressIndicator(), 50 | ), 51 | ), 52 | ); 53 | } 54 | } 55 | 56 | class _Failed extends StatelessWidget { 57 | const _Failed({ 58 | Key? key, 59 | required this.message, 60 | }) : super(key: key); 61 | 62 | final String message; 63 | 64 | @override 65 | Widget build(BuildContext context) { 66 | final theme = AppTheme.of(context); 67 | return DefaultTextStyle( 68 | style: TextStyle(color: theme.color.heroBarText1), 69 | child: Container( 70 | color: theme.color.heroBarBackground, 71 | child: Center( 72 | child: Text(message), 73 | ), 74 | ), 75 | ); 76 | } 77 | } 78 | 79 | class _Success extends StatefulWidget { 80 | const _Success({ 81 | Key? key, 82 | }) : super(key: key); 83 | 84 | @override 85 | State<_Success> createState() => _SuccessState(); 86 | } 87 | 88 | class _SuccessState extends State<_Success> { 89 | var accessKey = ''; 90 | @override 91 | Widget build(BuildContext context) { 92 | final theme = AppTheme.of(context); 93 | Future onSubmitted(String value) { 94 | final notifier = AppStateNotifier.of(context); 95 | return notifier.authenticate(accessKey.trim()); 96 | } 97 | 98 | final adminEmail = 99 | context.select(((AppStateNotifier value) => value.value.map( 100 | initializing: (x) => null, 101 | initializationFailed: (x) => null, 102 | initialized: (x) => x.info.adminEmail, 103 | notAuthenticated: (x) => x.info.adminEmail, 104 | authenticationFailed: (x) => x.info.adminEmail, 105 | authenticating: (x) => x.info.adminEmail, 106 | authenticated: (x) => x.info.adminEmail, 107 | ))); 108 | 109 | return DefaultTextStyle( 110 | style: TextStyle(color: theme.color.heroBarText1), 111 | child: Container( 112 | color: theme.color.heroBarBackground, 113 | alignment: Alignment.center, 114 | child: ConstrainedBox( 115 | constraints: BoxConstraints(maxWidth: theme.size.maxWidth), 116 | child: Padding( 117 | padding: EdgeInsets.all(theme.spacing.extraBig), 118 | child: Column( 119 | mainAxisAlignment: MainAxisAlignment.center, 120 | children: [ 121 | Row( 122 | mainAxisSize: MainAxisSize.min, 123 | children: [ 124 | Text( 125 | 'micro', 126 | style: theme.typography.title1.copyWith( 127 | fontWeight: FontWeight.w300, 128 | ), 129 | ), 130 | Text( 131 | 'pub', 132 | style: theme.typography.title1, 133 | ), 134 | ], 135 | ), 136 | Text( 137 | 'Private Dart package repository', 138 | style: theme.typography.paragraph2.copyWith( 139 | color: theme.color.heroBarText2, 140 | ), 141 | ), 142 | Gap(theme.spacing.extraBig), 143 | Row( 144 | children: [ 145 | Expanded( 146 | child: PasswordField( 147 | initial: accessKey, 148 | onChanged: (v) => setState(() { 149 | accessKey = v; 150 | }), 151 | onSubmitted: onSubmitted, 152 | ), 153 | ), 154 | Gap(theme.spacing.regular), 155 | AppFlatButton( 156 | title: 'Access', 157 | onTap: accessKey.trim().isEmpty 158 | ? null 159 | : () => onSubmitted(accessKey), 160 | style: theme.typography.paragraph1.copyWith( 161 | fontWeight: FontWeight.bold, 162 | ), 163 | ), 164 | ], 165 | ), 166 | Gap(theme.spacing.big), 167 | RichText( 168 | text: TextSpan( 169 | style: theme.typography.paragraph2.copyWith( 170 | color: theme.color.heroBarText1, 171 | ), 172 | children: [ 173 | const TextSpan( 174 | text: 175 | "If you don't have an access key, request one from "), 176 | if (adminEmail == null) 177 | const TextSpan( 178 | text: "your administrator", 179 | ), 180 | if (adminEmail != null) 181 | TextSpan( 182 | text: "your administrator", 183 | recognizer: TapGestureRecognizer() 184 | ..onTap = () async { 185 | launch( 186 | 'mailto:${Uri.encodeQueryComponent(adminEmail)}'); 187 | }, 188 | style: TextStyle( 189 | color: theme.color.bodyAccentText1, 190 | ), 191 | ) 192 | ], 193 | ), 194 | ), 195 | Gap(theme.spacing.extraBig), 196 | AppTextButton( 197 | title: 'micropub on Github', 198 | onTap: () { 199 | launch('https://github.com/aloisdeniel/micropub'); 200 | }, 201 | ), 202 | ], 203 | ), 204 | ), 205 | ), 206 | ), 207 | ); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /micropub/lib/src/server/controllers/api/packages.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | import 'package:logging/logging.dart'; 4 | import 'package:micropub/src/server/auth/auth.dart'; 5 | import 'package:micropub/src/server/controllers/api/api.dart'; 6 | import 'package:micropub/src/server/storage/storage.dart'; 7 | import 'package:micropub/src/server/utils/yaml.dart'; 8 | import 'package:micropub/src/shared/model.dart'; 9 | import 'package:shelf/shelf.dart' as shelf; 10 | import 'package:mime/mime.dart'; 11 | import 'package:collection/collection.dart'; 12 | import 'package:http_parser/http_parser.dart'; 13 | import 'package:shelf_router/shelf_router.dart'; 14 | import 'package:archive/archive.dart'; 15 | 16 | part 'packages.g.dart'; 17 | 18 | class PackagesApiController { 19 | const PackagesApiController({ 20 | required this.auth, 21 | required this.storage, 22 | Logger? logger, 23 | }) : _logger = logger; 24 | 25 | final Logger? _logger; 26 | Logger get logger => _logger ?? Logger.root; 27 | final MicropubAuth auth; 28 | final MicropubStorage storage; 29 | 30 | Router get router => _$PackagesApiControllerRouter(this); 31 | 32 | @Route.get('/') 33 | Future getVersions(shelf.Request req, String name) async { 34 | return req.withAuthorizations(MicropubAuthorization.read, () async { 35 | var package = await storage.queryPackage(name); 36 | 37 | if (package == null) { 38 | return shelf.Response.notFound('not found'); 39 | } 40 | 41 | var versionMaps = package.versions 42 | .map((item) => _versionToJson(item, req.requestedUri)) 43 | .toList(); 44 | 45 | return { 46 | 'name': name, 47 | 'latest': versionMaps.last, 48 | 'versions': versionMaps, 49 | }.asJsonResponse(); 50 | }); 51 | } 52 | 53 | @Route.get('//details') 54 | Future getPackageDetails( 55 | shelf.Request req, String name) async { 56 | return req.withAuthorizations(MicropubAuthorization.read, () async { 57 | var package = await storage.queryPackage(name); 58 | 59 | if (package == null) { 60 | return shelf.Response.notFound('not found'); 61 | } 62 | 63 | final details = MicropubPackageDetails( 64 | package: package, 65 | ); 66 | 67 | return details.toJson().asJsonResponse(); 68 | }); 69 | } 70 | 71 | @Route.get('//versions/') 72 | Future getVersion( 73 | shelf.Request req, String name, String version) async { 74 | return req.withAuthorizations(MicropubAuthorization.read, () async { 75 | try { 76 | version = Uri.decodeComponent(version); 77 | } catch (err) { 78 | print(err); 79 | } 80 | 81 | var package = await storage.queryPackage(name); 82 | 83 | if (package == null) { 84 | return shelf.Response.notFound('Not Found'); 85 | } 86 | 87 | var packageVersion = 88 | package.versions.firstWhereOrNull((item) => item.version == version); 89 | if (packageVersion == null) { 90 | return shelf.Response.notFound('Not Found'); 91 | } 92 | 93 | return _versionToJson(packageVersion, req.requestedUri).asJsonResponse(); 94 | }); 95 | } 96 | 97 | @Route.get('/') 98 | Future getPackages(shelf.Request req) async { 99 | var params = req.requestedUri.queryParameters; 100 | var size = int.tryParse(params['size'] ?? '') ?? 100; 101 | var page = int.tryParse(params['page'] ?? '') ?? 0; 102 | var sort = params['sort'] ?? 'download'; 103 | var q = params['q']; 104 | 105 | String? keyword; 106 | String? uploader; 107 | String? dependency; 108 | 109 | if (q == null) { 110 | } else if (q.startsWith('email:')) { 111 | uploader = q.substring(6).trim(); 112 | } else if (q.startsWith('dependency:')) { 113 | dependency = q.substring(11).trim(); 114 | } else { 115 | keyword = q; 116 | } 117 | 118 | final result = await storage.queryPackages( 119 | size: size, 120 | page: page, 121 | sort: sort, 122 | keyword: keyword, 123 | uploader: uploader, 124 | dependency: dependency, 125 | ); 126 | 127 | return result.toJson().asJsonResponse(); 128 | } 129 | 130 | @Route.get('/versions/new') 131 | Future getUploadUrl(shelf.Request req) async { 132 | return req.withAuthorizations(MicropubAuthorization.read, () async { 133 | return { 134 | 'url': req.requestedUri 135 | .resolve('/api/packages/versions/newUpload') 136 | .toString(), 137 | 'fields': {}, 138 | }.asJsonResponse(); 139 | }); 140 | } 141 | 142 | @Route.post('/versions/newUpload') 143 | Future upload(shelf.Request req) async { 144 | return req.withAuthorizations(MicropubAuthorization.write, () async { 145 | try { 146 | final uploader = req.context['email'] as String; 147 | 148 | var contentType = req.headers['content-type']; 149 | if (contentType == null) throw 'invalid content type'; 150 | 151 | var mediaType = MediaType.parse(contentType); 152 | var boundary = mediaType.parameters['boundary']; 153 | if (boundary == null) throw 'invalid boundary'; 154 | 155 | logger.info('New package upload...'); 156 | var transformer = MimeMultipartTransformer(boundary); 157 | MimeMultipart? fileData; 158 | 159 | // The map below makes the runtime type checker happy. 160 | // https://github.com/dart-lang/pub-dev/blob/19033f8154ca1f597ef5495acbc84a2bb368f16d/app/lib/fake/server/fake_storage_server.dart#L74 161 | final stream = req.read().map((a) => a).transform(transformer); 162 | await for (var part in stream) { 163 | if (fileData != null) continue; 164 | fileData = part; 165 | } 166 | 167 | logger.info('Reading multipart data...'); 168 | var bb = await fileData!.fold(BytesBuilder(), 169 | (BytesBuilder byteBuilder, d) => byteBuilder..add(d)); 170 | var tarballBytes = bb.takeBytes(); 171 | var tarBytes = GZipDecoder().decodeBytes(tarballBytes); 172 | var archive = TarDecoder().decodeBytes(tarBytes); 173 | ArchiveFile? pubspecArchiveFile; 174 | ArchiveFile? readmeFile; 175 | ArchiveFile? changelogFile; 176 | 177 | for (var file in archive.files) { 178 | if (file.name == 'pubspec.yaml') { 179 | pubspecArchiveFile = file; 180 | continue; 181 | } 182 | if (file.name.toLowerCase() == 'readme.md') { 183 | readmeFile = file; 184 | continue; 185 | } 186 | if (file.name.toLowerCase() == 'changelog.md') { 187 | changelogFile = file; 188 | continue; 189 | } 190 | } 191 | 192 | if (pubspecArchiveFile == null) { 193 | throw 'Did not find any pubspec.yaml file in upload. Aborting.'; 194 | } 195 | 196 | var pubspecYaml = utf8.decode(pubspecArchiveFile.content); 197 | var pubspec = loadYamlAsMap(pubspecYaml)!; 198 | 199 | var name = pubspec['name'] as String; 200 | var version = pubspec['version'] as String; 201 | 202 | logger.info('New version $version for package $name'); 203 | var package = await storage.queryPackage(name); 204 | 205 | // Package already exists 206 | if (package != null) { 207 | // Check uploaders 208 | if (package.uploaders?.contains(uploader) == false) { 209 | throw '$uploader is not an uploader of $name'; 210 | } 211 | 212 | // Check duplicated version 213 | var duplicated = package.versions 214 | .firstWhereOrNull((item) => version == item.version); 215 | if (duplicated != null) { 216 | throw 'version invalid: $name@$version already exists.'; 217 | } 218 | } 219 | 220 | // Upload package tarball to storage 221 | logger.info('Saving $name:$version package binary...'); 222 | await storage.upload(name, version, tarballBytes); 223 | 224 | String? readme; 225 | String? changelog; 226 | if (readmeFile != null) { 227 | readme = utf8.decode(readmeFile.content); 228 | } 229 | if (changelogFile != null) { 230 | changelog = utf8.decode(changelogFile.content); 231 | } 232 | 233 | // Write package meta to database 234 | var unpubVersion = MicropubVersion( 235 | version: version, 236 | pubspec: pubspec, 237 | pubspecYaml: pubspecYaml, 238 | uploader: uploader, 239 | readme: readme, 240 | changelog: changelog, 241 | createdAt: DateTime.now(), 242 | ); 243 | logger.info('Saving $name:$version metadata...'); 244 | await storage.addVersion(name, unpubVersion); 245 | 246 | return shelf.Response.found(req.requestedUri 247 | .resolve('/api/packages/versions/newUploadFinish') 248 | .toString()); 249 | } catch (err) { 250 | logger.warning('Package upload failed : $err'); 251 | return shelf.Response.found(req.requestedUri 252 | .resolve('/api/packages/versions/newUploadFinish?error=$err')); 253 | } 254 | }); 255 | } 256 | 257 | @Route.get('/versions/newUploadFinish') 258 | Future uploadFinish(shelf.Request req) async { 259 | return req.withAuthorizations(MicropubAuthorization.write, () async { 260 | var error = req.requestedUri.queryParameters['error']; 261 | if (error != null) { 262 | return _badRequest(error); 263 | } 264 | return _successMessage('Successfully uploaded package.'); 265 | }); 266 | } 267 | 268 | @Route.post('//uploaders') 269 | Future addUploader(shelf.Request req, String name) async { 270 | return req.withAuthorizations(MicropubAuthorization.write, () async { 271 | var body = await req.readAsString(); 272 | var email = Uri.splitQueryString(body)['email']!; 273 | var package = await storage.queryPackage(name); 274 | 275 | if (package?.uploaders?.contains(email) == false) { 276 | return _badRequest('no permission', status: HttpStatus.forbidden); 277 | } 278 | if (package?.uploaders?.contains(email) == true) { 279 | return _badRequest('email already exists'); 280 | } 281 | 282 | await storage.addUploader(name, email); 283 | return _successMessage('uploader added'); 284 | }); 285 | } 286 | 287 | @Route.delete('//uploaders/') 288 | Future removeUploader( 289 | shelf.Request req, String name, String email) async { 290 | return req.withAuthorizations(MicropubAuthorization.write, () async { 291 | email = Uri.decodeComponent(email); 292 | final operatorEmail = req.context['email'] as String; 293 | var package = await storage.queryPackage(name); 294 | 295 | if (package?.uploaders?.contains(operatorEmail) == false) { 296 | return _badRequest('no permission', status: HttpStatus.forbidden); 297 | } 298 | if (package?.uploaders?.contains(email) == false) { 299 | return _badRequest('email not uploader'); 300 | } 301 | 302 | await storage.removeUploader(name, email); 303 | return _successMessage('uploader removed'); 304 | }); 305 | } 306 | 307 | static shelf.Response _successMessage(String message) => { 308 | 'success': {'message': message} 309 | }.asJsonResponse(); 310 | 311 | static shelf.Response _badRequest(String message, 312 | {int status = HttpStatus.badRequest}) => 313 | shelf.Response( 314 | status, 315 | headers: {HttpHeaders.contentTypeHeader: ContentType.json.mimeType}, 316 | body: json.encode({ 317 | 'error': {'message': message} 318 | }), 319 | ); 320 | 321 | Map _versionToJson(MicropubVersion item, Uri baseUri) { 322 | var name = item.pubspec['name'] as String; 323 | var version = item.version; 324 | return { 325 | 'archive_url': baseUri 326 | .resolve('/packages/$name/versions/$version.tar.gz') 327 | .toString(), 328 | 'pubspec': item.pubspec, 329 | 'version': version, 330 | }; 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /website/lib/state/notifier.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:provider/provider.dart'; 6 | import 'package:micropub/client.dart'; 7 | import 'package:website/state/state.dart'; 8 | import 'package:shared_preferences/shared_preferences.dart'; 9 | 10 | class AppStateNotifier extends ValueNotifier { 11 | AppStateNotifier() 12 | : super(const AppState.initializing( 13 | client: MicropubApiClient( 14 | baseUri: kDebugMode ? 'http://localhost:8081/' : '/', 15 | ), 16 | )) { 17 | unawaited(initialize()); 18 | } 19 | 20 | static AppStateNotifier of(BuildContext context) { 21 | return Provider.of(context, listen: false); 22 | } 23 | 24 | MicropubApiClient get client => value.map( 25 | initializing: (initializing) => initializing.client, 26 | initialized: (x) => x.client, 27 | initializationFailed: (initializationFailed) => 28 | initializationFailed.client, 29 | notAuthenticated: (notAuthenticated) => notAuthenticated.client, 30 | authenticationFailed: (authenticationFailed) => 31 | authenticationFailed.client, 32 | authenticating: (authenticating) => authenticating.client, 33 | authenticated: (authenticated) => authenticated.client, 34 | ); 35 | 36 | T withInfo({ 37 | required T Function(MicropubServerInfo info) available, 38 | required T Function() notAvailable, 39 | }) => 40 | value.map( 41 | initializing: (initializing) => notAvailable(), 42 | initializationFailed: (initializationFailed) => notAvailable(), 43 | initialized: (initialized) => available(initialized.info), 44 | notAuthenticated: (notAuthenticated) => 45 | available(notAuthenticated.info), 46 | authenticationFailed: (authenticationFailed) => 47 | available(authenticationFailed.info), 48 | authenticating: (authenticating) => available(authenticating.info), 49 | authenticated: (authenticated) => available(authenticated.info), 50 | ); 51 | 52 | Future initialize() async { 53 | final prefs = await SharedPreferences.getInstance(); 54 | final accessKey = prefs.getString('access-key'); 55 | try { 56 | final newInfo = await client.info(); 57 | value = AppState.initialized( 58 | client: client, 59 | info: newInfo, 60 | ); 61 | if (accessKey == null) { 62 | value = AppState.notAuthenticated( 63 | client: client, 64 | info: newInfo, 65 | ); 66 | } else { 67 | await authenticate(accessKey); 68 | } 69 | } catch (e) { 70 | value = AppState.initializationFailed( 71 | client: client, 72 | error: e, 73 | ); 74 | } 75 | } 76 | 77 | Future authenticate(String accessKey) async { 78 | return withInfo( 79 | available: (info) async { 80 | try { 81 | value = AppState.authenticating( 82 | info: info, 83 | client: client, 84 | ); 85 | final authClient = MicropubApiAuthenticatedClient( 86 | baseUri: client.baseUri, 87 | accessKey: accessKey, 88 | ); 89 | final me = await authClient.me(); 90 | value = AppState.authenticated( 91 | me: me, 92 | info: info, 93 | client: authClient, 94 | package: const PackageState.notLoaded(), 95 | packages: const PackagesState.notLoaded( 96 | page: -1, 97 | pageSize: 100, 98 | query: '', 99 | ), 100 | admin: me.authorizations.contains(MicropubAuthorization.admin) 101 | ? const AdminState.notLoaded() 102 | : const AdminState.notAuthorized(), 103 | ); 104 | 105 | final prefs = await SharedPreferences.getInstance(); 106 | await prefs.setString('access-key', accessKey); 107 | } catch (e) { 108 | value = AppState.authenticationFailed( 109 | client: client, 110 | info: info, 111 | ); 112 | } 113 | }, 114 | notAvailable: () => Future.value(), 115 | ); 116 | } 117 | 118 | Future refreshAdmin() { 119 | return value.maybeMap( 120 | authenticated: (authenticated) { 121 | return authenticated.admin.maybeMap( 122 | notAuthorized: (notAuthorized) => Future.value(), 123 | loading: (loading) => Future.value(), 124 | orElse: () async { 125 | try { 126 | value = authenticated.copyWith( 127 | admin: const AdminState.loading(), 128 | ); 129 | final keys = await authenticated.client.adminAccessKeys(); 130 | 131 | value = authenticated.copyWith( 132 | admin: keys.isEmpty 133 | ? const AdminState.empty() 134 | : AdminState.result( 135 | accessKeys: keys, 136 | ), 137 | ); 138 | } catch (e, stackTrace) { 139 | value = authenticated.copyWith( 140 | admin: AdminState.failed( 141 | error: e, 142 | stackTrace: stackTrace, 143 | ), 144 | ); 145 | } 146 | }, 147 | ); 148 | }, 149 | orElse: () => Future.value(), 150 | ); 151 | } 152 | 153 | Future createAccessKey( 154 | String email, 155 | bool read, 156 | bool write, 157 | bool admin, 158 | ) { 159 | return value.maybeMap( 160 | authenticated: (authenticated) { 161 | return authenticated.admin.maybeMap( 162 | notAuthorized: (notAuthorized) => throw Exception(), 163 | loading: (loading) => throw Exception(), 164 | orElse: () async { 165 | return await authenticated.client.adminCreateAccessKey( 166 | email, 167 | read, 168 | write, 169 | admin, 170 | ); 171 | }, 172 | ); 173 | }, 174 | orElse: () => throw Exception(), 175 | ); 176 | } 177 | 178 | Future revokeAccessKey(MicropubAccessKey key) { 179 | return value.maybeMap( 180 | authenticated: (authenticated) { 181 | return authenticated.admin.maybeMap( 182 | notAuthorized: (notAuthorized) => throw Exception(), 183 | loading: (loading) => throw Exception(), 184 | orElse: () async { 185 | return await authenticated.client.adminRevokeAccessKey(key.id); 186 | }, 187 | ); 188 | }, 189 | orElse: () => throw Exception(), 190 | ); 191 | } 192 | 193 | Future disconnect() async { 194 | return withInfo( 195 | available: (info) async { 196 | value = AppState.notAuthenticated( 197 | client: client, 198 | info: info, 199 | ); 200 | final prefs = await SharedPreferences.getInstance(); 201 | await prefs.remove('access-key'); 202 | }, 203 | notAvailable: () => Future.value(), 204 | ); 205 | } 206 | 207 | Future loadPackages(int page, String query) { 208 | return value.maybeMap( 209 | authenticated: (authenticated) async { 210 | authenticated.packages.maybeMap( 211 | result: (result) async { 212 | if (result.query != query || 213 | (page >= 0 && page < result.totalPages)) { 214 | value = authenticated.copyWith( 215 | packages: PackagesState.loading( 216 | page: page, 217 | query: query, 218 | pageSize: authenticated.packages.pageSize, 219 | ), 220 | ); 221 | value = authenticated.copyWith( 222 | packages: await _loadPackages( 223 | authenticated.client, 224 | page, 225 | authenticated.packages.pageSize, 226 | query, 227 | ), 228 | ); 229 | } 230 | }, 231 | orElse: () async { 232 | value = authenticated.copyWith( 233 | packages: PackagesState.loading( 234 | page: page, 235 | query: query, 236 | pageSize: authenticated.packages.pageSize, 237 | ), 238 | ); 239 | value = authenticated.copyWith( 240 | packages: await _loadPackages( 241 | authenticated.client, 242 | page, 243 | authenticated.packages.pageSize, 244 | query, 245 | ), 246 | ); 247 | }, 248 | ); 249 | }, 250 | orElse: () => Future.value(), 251 | ); 252 | } 253 | 254 | Future loadPackage(String name) { 255 | return value.maybeMap( 256 | authenticated: (authenticated) async { 257 | authenticated.package.maybeMap( 258 | notLoaded: (notLoaded) async { 259 | value = authenticated.copyWith( 260 | package: PackageState.loading(packageName: name), 261 | ); 262 | value = authenticated.copyWith( 263 | package: await _loadPackage( 264 | authenticated.client, 265 | name, 266 | ), 267 | ); 268 | }, 269 | failed: (notLoaded) async { 270 | value = authenticated.copyWith( 271 | package: PackageState.loading(packageName: name), 272 | ); 273 | value = authenticated.copyWith( 274 | package: await _loadPackage( 275 | authenticated.client, 276 | name, 277 | ), 278 | ); 279 | }, 280 | loading: (result) async { 281 | if (result.packageName != name) { 282 | value = authenticated.copyWith( 283 | package: PackageState.loading(packageName: name), 284 | ); 285 | value = authenticated.copyWith( 286 | package: await _loadPackage( 287 | authenticated.client, 288 | name, 289 | ), 290 | ); 291 | } 292 | }, 293 | result: (result) async { 294 | if (result.packageName != name) { 295 | value = authenticated.copyWith( 296 | package: PackageState.loading(packageName: name), 297 | ); 298 | value = authenticated.copyWith( 299 | package: await _loadPackage( 300 | authenticated.client, 301 | name, 302 | ), 303 | ); 304 | } 305 | }, 306 | orElse: () => Future.value(), 307 | ); 308 | }, 309 | orElse: () => Future.value(), 310 | ); 311 | } 312 | 313 | Future _loadPackages( 314 | MicropubApiAuthenticatedClient client, 315 | int page, 316 | int pageSize, 317 | String query, 318 | ) async { 319 | try { 320 | final result = await client.getPackages( 321 | size: pageSize, 322 | page: page, 323 | query: query, 324 | ); 325 | 326 | return PackagesState.result( 327 | pageSize: pageSize, 328 | page: page, 329 | query: query, 330 | result: result, 331 | totalPages: result.count ~/ pageSize, 332 | ); 333 | } catch (e, stackTrace) { 334 | return PackagesState.failed( 335 | pageSize: pageSize, 336 | page: page, 337 | query: query, 338 | error: e, 339 | stackTrace: stackTrace, 340 | ); 341 | } 342 | } 343 | 344 | Future _loadPackage( 345 | MicropubApiAuthenticatedClient client, String name) async { 346 | try { 347 | final result = await client.getPackageDetails(name); 348 | 349 | return PackageState.result( 350 | packageName: name, 351 | result: result, 352 | ); 353 | } catch (e, stackTrace) { 354 | return PackageState.failed( 355 | packageName: name, 356 | error: e, 357 | stackTrace: stackTrace, 358 | ); 359 | } 360 | } 361 | } 362 | --------------------------------------------------------------------------------